diff --git a/hydradx/model/amm/agents.py b/hydradx/model/amm/agents.py index 3178e2a7..edfa4620 100644 --- a/hydradx/model/amm/agents.py +++ b/hydradx/model/amm/agents.py @@ -9,7 +9,8 @@ def __init__(self, share_prices: dict[str: float] = None, delta_r: dict[str: float] = None, trade_strategy: any = None, - unique_id: str = 'agent' + unique_id: str = 'agent', + nfts: dict[str: any] = None, ): """ holdings should be in the form of: @@ -29,6 +30,7 @@ def __init__(self, self.trade_strategy = trade_strategy self.asset_list = list(self.holdings.keys()) self.unique_id = unique_id + self.nfts = nfts or {} def __repr__(self): precision = 10 @@ -52,7 +54,8 @@ def copy(self): share_prices={k: v for k, v in self.share_prices.items()}, delta_r={k: v for k, v in self.delta_r.items()}, trade_strategy=self.trade_strategy, - unique_id=self.unique_id + unique_id=self.unique_id, + nfts={id: copy.deepcopy(nft) for id, nft in self.nfts.items()} ) copy_self.initial_holdings = {k: v for k, v in self.initial_holdings.items()} copy_self.asset_list = [tkn for tkn in self.asset_list] diff --git a/hydradx/model/amm/omnipool_amm.py b/hydradx/model/amm/omnipool_amm.py index 87895b6f..b5d5070b 100644 --- a/hydradx/model/amm/omnipool_amm.py +++ b/hydradx/model/amm/omnipool_amm.py @@ -105,9 +105,9 @@ def __init__(self, if oracles is None or 'price' not in oracles: if last_oracle_values is None or 'price' not in last_oracle_values: - self.oracles['price'] = Oracle(sma_equivalent_length=19, first_block=Block(self)) + self.oracles['price'] = Oracle(sma_equivalent_length=9, first_block=Block(self)) else: - self.oracles['price'] = Oracle(sma_equivalent_length=19, first_block=Block(self), + self.oracles['price'] = Oracle(sma_equivalent_length=9, first_block=Block(self), last_values=last_oracle_values['price']) if last_oracle_values is not None and oracles is not None: self.oracles.update({ @@ -415,7 +415,7 @@ def get_sub_pool(self, tkn: str): for pool in self.sub_pools.values(): if tkn in pool.asset_list: return pool.unique_id - + def swap( self, agent: Agent, @@ -445,12 +445,12 @@ def swap( buy_quantity=buy_quantity, sell_quantity=sell_quantity ) - + elif tkn_sell == 'LRNA': return_val = self.lrna_swap(agent, buy_quantity, -sell_quantity, tkn_buy, modify_imbalance) elif tkn_buy == 'LRNA': return_val = self.lrna_swap(agent, -sell_quantity, buy_quantity, tkn_sell, modify_imbalance) - + elif buy_quantity and not sell_quantity: # back into correct delta_Ri, then execute sell delta_Ri = self.calculate_sell_from_buy(tkn_buy, tkn_sell, buy_quantity) @@ -489,7 +489,7 @@ def swap( if self.liquidity[i] + sell_quantity > 10 ** 12: return self.fail_transaction('Asset liquidity cannot exceed 10 ^ 12.', agent) - + # per-block trade limits if ( -delta_Rj - self.current_block.volume_in[tkn_buy] + self.current_block.volume_out[tkn_buy] @@ -511,14 +511,14 @@ def swap( self.liquidity[j] += -buy_quantity or delta_Rj self.lrna['HDX'] += delta_QH self.lrna_imbalance += delta_L - + if j not in agent.holdings: agent.holdings[j] = 0 agent.holdings[i] -= delta_Ri agent.holdings[j] -= -buy_quantity or delta_Rj - + return_val = self - + # update oracle if tkn_buy in self.current_block.asset_list: buy_quantity = old_buy_liquidity - self.liquidity[tkn_buy] @@ -529,7 +529,7 @@ def swap( self.current_block.volume_in[tkn_sell] += sell_quantity self.current_block.price[tkn_sell] = self.lrna[tkn_sell] / self.liquidity[tkn_sell] return return_val - + def lrna_swap( self, agent: Agent, @@ -541,7 +541,7 @@ def lrna_swap( """ Execute LRNA swap in place (modify and return) """ - + if delta_qa < 0: asset_fee = self.asset_fee[tkn].compute() if -delta_qa + self.lrna[tkn] <= 0: @@ -557,7 +557,7 @@ def lrna_swap( self.lrna[tkn] += delta_q self.liquidity[tkn] += -delta_ra - + elif delta_ra > 0: asset_fee = self.asset_fee[tkn].compute() if -delta_ra + self.liquidity[tkn] <= 0: @@ -573,7 +573,7 @@ def lrna_swap( self.lrna[tkn] += delta_q self.liquidity[tkn] -= delta_ra - + # buying LRNA elif delta_qa > 0: lrna_fee = self.lrna_fee[tkn].compute() @@ -587,13 +587,13 @@ def lrna_swap( self.liquidity[tkn] += -delta_ra # if modify_imbalance: # self.lrna_imbalance += - delta_qi * (q + l) / (q + delta_qi) - delta_qi - + # we assume, for now, that buying LRNA is only possible when modify_imbalance = False lrna_fee_amt = -(delta_qa + delta_qi) delta_l = min(-self.lrna_imbalance, lrna_fee_amt) self.lrna_imbalance += delta_l self.lrna["HDX"] += lrna_fee_amt - delta_l - + elif delta_ra < 0: lrna_fee = self.lrna_fee[tkn].compute() # delta_ri = -delta_ra @@ -605,16 +605,16 @@ def lrna_swap( self.liquidity[tkn] -= delta_ra # if modify_imbalance: # self.lrna_imbalance += - delta_qi * (q + l) / (q + delta_qi) - delta_qi - + # we assume, for now, that buying LRNA is only possible when modify_imbalance = False lrna_fee_amt = -(delta_qa + delta_qi) delta_l = min(-self.lrna_imbalance, lrna_fee_amt) self.lrna_imbalance += delta_l self.lrna["HDX"] += lrna_fee_amt - delta_l - + else: return self.fail_transaction('All deltas are zero.', agent) - + if 'LRNA' not in agent.holdings: agent.holdings['LRNA'] = 0 if tkn not in agent.holdings: @@ -622,9 +622,9 @@ def lrna_swap( agent.holdings['LRNA'] += delta_qa agent.holdings[tkn] += delta_ra - + return self - + def stable_swap( self, agent: Agent, @@ -659,7 +659,7 @@ def stable_swap( delta_shares = agent.holdings[sub_pool.unique_id] - agent_shares sub_pool.remove_liquidity(agent, delta_shares, tkn_buy) return self - + elif sub_pool_sell_id and tkn_buy in self.asset_list: sub_pool: StableSwapPoolState = self.sub_pools[sub_pool_sell_id] if sell_quantity: @@ -687,13 +687,13 @@ def stable_swap( return self.fail_transaction(sub_pool.fail, agent) self.swap(agent, tkn_buy, sub_pool.unique_id, buy_quantity) return self - + elif sub_pool_buy_id and tkn_sell in self.asset_list: sub_pool: StableSwapPoolState = self.sub_pools[sub_pool_buy_id] if buy_quantity: # buy a stableswap asset with an omnipool asset shares_traded = sub_pool.calculate_withdrawal_shares(tkn_buy, buy_quantity) - + # buy shares in the subpool self.swap(agent, tkn_buy=sub_pool.unique_id, tkn_sell=tkn_sell, buy_quantity=shares_traded) if self.fail: @@ -753,7 +753,7 @@ def stable_swap( ) if pool_buy.fail: return self.fail_transaction(pool_buy.fail, agent) - + # if all three parts succeeded, then we're good! return self elif sell_quantity: @@ -806,7 +806,8 @@ def create_sub_pool( 'subpool_shares': self.liquidity[tkn] * tkns_migrate[tkn] / self.liquidity[tkn] } for tkn in tkns_migrate } - new_sub_pool.shares = sum([self.liquidity[tkn] * tkns_migrate[tkn] / self.liquidity[tkn] for tkn in tkns_migrate]) + new_sub_pool.shares = sum( + [self.liquidity[tkn] * tkns_migrate[tkn] / self.liquidity[tkn] for tkn in tkns_migrate]) self.sub_pools[unique_id] = new_sub_pool self.add_token( unique_id, @@ -818,7 +819,7 @@ def create_sub_pool( for tkn in tkns_migrate ]) ) - + # remove assets from Omnipool for tkn in tkns_migrate: self.lrna[tkn] -= self.lrna[tkn] * tkns_migrate[tkn] / self.liquidity[tkn] @@ -826,7 +827,7 @@ def create_sub_pool( if self.liquidity[tkn] == 0: self.asset_list.remove(tkn) return self - + def migrate_asset(self, tkn_migrate: str, sub_pool_id: str): """ Move an asset from the Omnipool into a stableswap subpool. @@ -840,26 +841,26 @@ def migrate_asset(self, tkn_migrate: str, sub_pool_id: str): self.protocol_shares[s] += ( self.shares[s] * self.lrna[i] / self.lrna[s] * self.protocol_shares[i] / self.shares[i] ) - + sub_pool.conversion_metrics[i] = { 'price': self.lrna[i] / self.lrna[s] * sub_pool.shares / self.liquidity[i], 'old_shares': self.shares[i], 'omnipool_shares': self.lrna[i] * self.shares[s] / self.lrna[s], 'subpool_shares': self.lrna[i] * sub_pool.shares / self.lrna[s] } - + self.shares[s] += self.lrna[i] * self.shares[s] / self.lrna[s] self.liquidity[s] += self.lrna[i] * sub_pool.shares / self.lrna[s] sub_pool.shares += self.lrna[i] * sub_pool.shares / self.lrna[s] self.lrna[s] += self.lrna[i] - + # remove asset from omnipool and add it to subpool self.lrna[i] = 0 self.liquidity[i] = 0 self.asset_list.remove(i) sub_pool.asset_list.append(i) return self - + def migrate_lp( self, agent: Agent, @@ -883,10 +884,71 @@ def migrate_lp( agent.holdings[old_pool_id] / conversions['old_shares'] * conversions['subpool_shares'] ) agent.holdings[old_pool_id] = 0 - + return self - - def calculate_remove_liquidity(self, agent: Agent, quantity: float, tkn_remove: str): + + def calculate_remove_liquidity(self, agent: Agent, quantity: float = None, tkn_remove: str = None, + nft_id: str = None): + """ + If quantity is specified and nft_id is specified, remove specified quantity of shares from specified position. + If quantity is specified and nft_id is unspecified, remove specified quantity of shares from holdings. + If quantity is unspecified and nft_id is specified, remove specified position. + If quantity is unspecified and nft_id is unspecified, remove all liquidity. + """ + + if tkn_remove is None: + if nft_id is None: + raise AssertionError('tkn_remove must be specified if nft_id is not provided.') + else: + tkn_remove = agent.nfts[nft_id].tkn + + delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l, nft_ids = 0, 0, 0, 0, 0, 0, [] + if quantity is not None: + if nft_id is None: # remove specified quantity of shares from holdings + k = (self.unique_id, tkn_remove) + delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l = self._calculate_remove_one_position( + quantity=quantity, tkn_remove=tkn_remove, share_price=agent.share_prices[k] + ) + else: # remove specified quantity of shares from specified position + delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l = self._calculate_remove_one_position( + quantity=quantity, tkn_remove=tkn_remove, share_price=agent.nfts[nft_id].price + ) + nft_ids = [nft_id] + else: + if nft_id is not None: # remove specified position + delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l = self._calculate_remove_one_position( + quantity=agent.nfts[nft_id].shares, tkn_remove=tkn_remove, share_price=agent.nfts[nft_id].price + ) + nft_ids = [nft_id] + else: # remove all liquidity + for nft_id in agent.nfts: + nft = agent.nfts[nft_id] + if isinstance(nft, OmnipoolLiquidityPosition): + if nft.pool_id == self.unique_id and nft.tkn == tkn_remove: + nft_ids.append(nft_id) + dqa, dr, dq, ds, db, dl = self._calculate_remove_one_position( + quantity=nft.shares, tkn_remove=tkn_remove, share_price=nft.price + ) + delta_qa += dqa + delta_r += dr + delta_q += dq + delta_s += ds + delta_b += db + delta_l += dl + if (self.unique_id, tkn_remove) in agent.holdings: + dqa, dr, dq, ds, db, dl = self._calculate_remove_one_position( + quantity=agent.holdings[(self.unique_id, tkn_remove)], tkn_remove=tkn_remove, + share_price=agent.share_prices[(self.unique_id, tkn_remove)] + ) + delta_qa += dqa + delta_r += dr + delta_q += dq + delta_s += ds + delta_b += db + delta_l += dl + return delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l, nft_ids + + def _calculate_remove_one_position(self, quantity, tkn_remove, share_price): """ calculated the pool and agent deltas for removing liquidity from a sub pool return as a tuple in this order: @@ -899,77 +961,75 @@ def calculate_remove_liquidity(self, agent: Agent, quantity: float, tkn_remove: delta_b (protocol shares) delta_l (LRNA imbalance) """ + k = (self.unique_id, tkn_remove) + quantity = -abs(quantity) assert quantity <= 0, f"delta_S cannot be positive: {quantity}" - # assert tkn_remove in self.asset_list, f"invalid token name: {tkn_remove}" if tkn_remove not in self.asset_list: raise AssertionError(f"Invalid token name: {tkn_remove}") - # return 0, 0, 0, 0, 0, 0 - - if (self.unique_id, tkn_remove) not in agent.share_prices: - raise AssertionError(f"Agent does not have a share price for {tkn_remove}") - # return 0, 0, 0, 0, 0, 0 - + # determine if they should get some LRNA back as well as the asset they invested piq = lrna_price(self, tkn_remove) - p0 = agent.share_prices[(self.unique_id, tkn_remove)] + p0 = share_price mult = (piq - p0) / (piq + p0) - + # Share update delta_b = max(mult * quantity, 0) delta_s = quantity + delta_b - + # Token amounts update delta_q = self.lrna[tkn_remove] / self.shares[tkn_remove] * delta_s delta_r = delta_q / piq - + if piq > p0: # prevents rounding errors - if 'LRNA' not in agent.holdings: - agent.holdings['LRNA'] = 0 delta_qa = -piq * ( 2 * piq / (piq + p0) * quantity / self.shares[tkn_remove] * self.liquidity[tkn_remove] - delta_r ) else: delta_qa = 0 - + if hasattr(self, 'withdrawal_fee') and self.withdrawal_fee > 0: # calculate withdraw fee diff = abs(self.oracles['price'].price[tkn_remove] - piq) / self.oracles['price'].price[tkn_remove] fee = max(min(diff, 1), self.min_withdrawal_fee) - + delta_r *= 1 - fee delta_qa *= 1 - fee delta_q *= 1 - fee - + # L update: LRNA fees to be burned before they will start to accumulate again delta_l = delta_r * piq * self.lrna_imbalance / self.lrna_total - + return delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l - + def add_liquidity( self, agent: Agent = None, quantity: float = 0, - tkn_add: str = '' + tkn_add: str = '', + nft_id: str = None ): """Compute new state after liquidity addition""" - + if quantity <= 0: return self.fail_transaction('Quantity must be non-negative.', agent) - + delta_Q = lrna_price(self, tkn_add) * quantity - if (self.unique_id, tkn_add) in agent.holdings: - if agent.holdings[(self.unique_id, tkn_add)] != 0: - return self.fail_transaction(f'Agent already has liquidity in pool {tkn_add}.', agent) - else: - agent.holdings[(self.unique_id, tkn_add)] = 0 - + + if nft_id is None and (self.unique_id, tkn_add) in agent.holdings: + return self.fail_transaction( + 'Agent already has liquidity in this pool. Try using nft_id input.', agent + ) + + if nft_id is not None and nft_id in agent.nfts: + raise AssertionError('Agent already has an NFT with this ID.') + if agent.holdings[tkn_add] < quantity: return self.fail_transaction( f'Agent has insufficient funds ({agent.holdings[tkn_add]} < {quantity}).', agent ) - + if (self.lrna[tkn_add] + delta_Q) / (self.lrna_total + delta_Q) > self.weight_cap[tkn_add]: return self.fail_transaction( 'Transaction rejected because it would exceed the weight cap in pool[{i}].', agent @@ -978,7 +1038,7 @@ def add_liquidity( if self.tvl_cap < float('inf'): if (self.total_value_locked() + quantity * usd_price(self, tkn_add)) > self.tvl_cap: return self.fail_transaction('Transaction rejected because it would exceed the TVL cap.', agent) - + # assert quantity > 0, f"delta_R must be positive: {quantity}" if tkn_add not in self.asset_list: for sub_pool in self.sub_pools.values(): @@ -995,7 +1055,8 @@ def add_liquidity( return self.add_liquidity( agent=agent, quantity=agent.holdings[sub_pool.unique_id] - old_agent_holdings, - tkn_add=sub_pool.unique_id + tkn_add=sub_pool.unique_id, + nft_id=nft_id ) raise AssertionError(f"invalid value for i: {tkn_add}") else: @@ -1004,43 +1065,64 @@ def add_liquidity( return self.fail_transaction( 'Transaction rejected because it would exceed the max LP per block.', agent ) - + # Share update if self.shares[tkn_add]: shares_added = (delta_Q / self.lrna[tkn_add]) * self.shares[tkn_add] else: shares_added = delta_Q self.shares[tkn_add] += shares_added - - # shares go to provisioning agent - agent.holdings[(self.unique_id, tkn_add)] += shares_added - + # L update: LRNA fees to be burned before they will start to accumulate again delta_L = quantity * lrna_price(self, tkn_add) * self.lrna_imbalance / self.lrna_total self.lrna_imbalance += delta_L - + # LRNA add (mint) self.lrna[tkn_add] += delta_Q - + # Token amounts update self.liquidity[tkn_add] += quantity + + # agent update agent.holdings[tkn_add] -= quantity - - # set price at which liquidity was added - # use the minimum of the oracle price and the current price - agent.share_prices[(self.unique_id, tkn_add)] = lrna_price(self, tkn_add) - agent.delta_r[(self.unique_id, tkn_add)] = quantity - + if nft_id is None: + k = (self.unique_id, tkn_add) + agent.holdings[k] = 0 + # shares go to provisioning agent + agent.holdings[k] += shares_added + # set price at which liquidity was added + agent.share_prices[k] = lrna_price(self, tkn_add) + agent.delta_r[k] = quantity + else: + lp_position = OmnipoolLiquidityPosition(tkn_add, lrna_price(self, tkn_add), shares_added, quantity, + self.unique_id) + agent.nfts[nft_id] = lp_position + # update block self.current_block.lps[tkn_add] += quantity - + return self - - def remove_liquidity(self, agent: Agent, quantity: float, tkn_remove: str): + + def remove_liquidity(self, agent: Agent, quantity: float = None, tkn_remove: str = '', nft_id: str = None): """ Remove liquidity from a sub pool. + If quantity is specified and nft_id is specified, remove specified quantity of shares from specified position. + If quantity is specified and nft_id is unspecified, remove specified quantity of shares from holdings. + If quantity is unspecified and nft_id is specified, remove specified position. + If quantity is unspecified and nft_id is unspecified, remove all liquidity. """ - + + # if i is None: + # k = (self.unique_id, tkn_remove) + # else: + # k = (self.unique_id + "_" + str(i), tkn_remove) + + k = (self.unique_id, tkn_remove) + if nft_id is not None: + if nft_id not in agent.nfts: + return self.fail_transaction('Agent does not have liquidity position with specified nft_id.', agent) + tkn_remove = agent.nfts[nft_id].tkn + if tkn_remove not in self.asset_list: for sub_pool in self.sub_pools.values(): if tkn_remove in sub_pool.asset_list: @@ -1051,15 +1133,27 @@ def remove_liquidity(self, agent: Agent, quantity: float, tkn_remove: str): return self.fail_transaction(sub_pool.fail, agent) else: return self - - raise AssertionError(f"invalid value for i: {tkn_remove}") + + raise AssertionError(f"invalid value for tkn_remove: {tkn_remove}") if quantity == 0: return self - if not agent.is_holding((self.unique_id, tkn_remove)): - return self.fail_transaction('Agent does not have liquidity in this pool.', agent) - - if self.remove_liquidity_volatility_threshold: + # if nft_id is None and not agent.is_holding(k): + # return self.fail_transaction('Agent does not have liquidity in this pool.', agent) + if nft_id is None: + if quantity is not None and not agent.is_holding(k, quantity): + return self.fail_transaction('Agent does not have enough liquidity in this pool.', agent) + else: + if nft_id not in agent.nfts: + return self.fail_transaction('Agent does not have liquidity position with specified nft_id.', agent) + elif agent.nfts[nft_id].pool_id != self.unique_id: + return self.fail_transaction('Specified position is wrong pool.', agent) + elif agent.nfts[nft_id].tkn != tkn_remove: + return self.fail_transaction('Specified position is wrong asset.', agent) + elif quantity is not None and agent.nfts[nft_id].shares < quantity: + return self.fail_transaction('Agent does not have enough shares in specified position.', agent) + + if self.remove_liquidity_volatility_threshold and self.remove_liquidity_volatility_threshold < float('inf'): if self.oracles['price']: volatility = abs( self.oracles['price'].price[tkn_remove] / self.current_block.price[tkn_remove] - 1 @@ -1069,43 +1163,62 @@ def remove_liquidity(self, agent: Agent, quantity: float, tkn_remove: str): f"Withdrawal rejected because the oracle volatility is too high: {volatility} > " f"{self.remove_liquidity_volatility_threshold}", agent ) - - quantity = abs(quantity) + + val = self.calculate_remove_liquidity(agent, quantity, tkn_remove, nft_id) + delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l = val[:6] + if len(val) == 7: + nft_ids = val[6] + max_remove = ( self.max_withdrawal_per_block * self.shares[tkn_remove] - self.current_block.withdrawals[tkn_remove] ) - if quantity > max_remove: - # quantity = max_remove + if abs(delta_s) > max_remove: return self.fail_transaction( - f"Transaction rejected because it would exceed the withdrawal limit: {quantity} > {max_remove}", agent + f"Transaction rejected because it would exceed the withdrawal limit: {abs(delta_s)} > {max_remove}", + agent ) - - delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l = self.calculate_remove_liquidity( - agent, quantity, tkn_remove - ) + if delta_r + self.liquidity[tkn_remove] < 0: return self.fail_transaction('Cannot remove more liquidity than exists in the pool.', agent) - elif quantity > agent.holdings[(self.unique_id, tkn_remove)]: - return self.fail_transaction('Cannot remove more liquidity than the agent has invested.', agent) - + self.liquidity[tkn_remove] += delta_r self.shares[tkn_remove] += delta_s self.protocol_shares[tkn_remove] += delta_b self.lrna[tkn_remove] += delta_q self.lrna_imbalance += delta_l - # agent.delta_r[(self.unique_id, tkn_remove)] *= quantity / agent.holdings[(self.unique_id, tkn_remove)] - if 'LRNA' not in agent.holdings: - agent.holdings['LRNA'] = 0 - agent.holdings['LRNA'] += delta_qa - agent.holdings[(self.unique_id, tkn_remove)] -= quantity - if agent.holdings[(self.unique_id, tkn_remove)] == 0: - agent.share_prices[(self.unique_id, tkn_remove)] = 0 + + # distribute tokens to agent + if delta_qa > 0: + if 'LRNA' not in agent.holdings: + agent.holdings['LRNA'] = 0 + agent.holdings['LRNA'] += delta_qa + if tkn_remove not in agent.holdings: + agent.holdings[tkn_remove] = 0 agent.holdings[tkn_remove] -= delta_r - - self.current_block.withdrawals[tkn_remove] += quantity + + # remove lp position(s) + if nft_id is None: + if quantity is not None: + agent.holdings[k] -= quantity + if agent.holdings[k] == 0: + agent.share_prices[k] = 0 + else: + if k in agent.holdings: + agent.holdings[k] = 0 + agent.share_prices[k] = 0 + for nft_id in nft_ids: + del agent.nfts[nft_id] + else: + if quantity is not None: + agent.nfts[nft_id].shares -= quantity + if quantity is None or agent.nfts[nft_id].shares == 0: + del agent.nfts[nft_id] + + self.current_block.withdrawals[tkn_remove] += abs(delta_s) return self - def value_assets(self, assets: dict[str, float], equivalency_map: dict[str, str] = None, stablecoin: str = None) -> float: + def value_assets(self, assets: dict[str, float], equivalency_map: dict[str, str] = None, + stablecoin: str = None) -> float: # assets is a dict of token: quantity # returns the value of the assets in USD if stablecoin is None: @@ -1139,6 +1252,18 @@ def value_assets(self, assets: dict[str, float], equivalency_map: dict[str, str] return value +class OmnipoolLiquidityPosition: + def __init__(self, tkn: str, price: float, shares: float, delta_r: float, pool_id: str = None): + self.tkn = tkn + self.price = price + self.shares = shares + self.delta_r = delta_r + self.pool_id = pool_id + + def copy(self): + return OmnipoolLiquidityPosition(self.tkn, self.price, self.shares, self.delta_r, self.pool_id) + + class OmnipoolArchiveState: def __init__(self, state: OmnipoolState): self.asset_list = [tkn for tkn in state.asset_list] @@ -1273,27 +1398,29 @@ def simulate_add_liquidity( old_state: OmnipoolState, old_agent: Agent, quantity: float = 0, - tkn_add: str = '' + tkn_add: str = '', + nft_id: str = None ) -> tuple[OmnipoolState, Agent]: """Copy state, then add liquidity and return new state""" new_state = old_state.copy() new_agent = old_agent.copy() - new_state.add_liquidity(new_agent, quantity, tkn_add) + new_state.add_liquidity(new_agent, quantity, tkn_add, nft_id) return new_state, new_agent def simulate_remove_liquidity( old_state: OmnipoolState, old_agent: Agent, - quantity: float, - tkn_remove: str + quantity: float = None, + tkn_remove: str = '', + nft_id: str = None ) -> tuple[OmnipoolState, Agent]: """Compute new state after liquidity removal""" new_state = old_state.copy() new_agent = old_agent.copy() - new_state.remove_liquidity(new_agent, quantity, tkn_remove) + new_state.remove_liquidity(new_agent, quantity, tkn_remove, nft_id) return new_state, new_agent @@ -1426,44 +1553,53 @@ def cash_out_omnipool(omnipool: OmnipoolState, agent: Agent, prices) -> float: and then sell at current spot prices """ - if 'LRNA' not in agent.holdings: - agent.holdings['LRNA'] = 0 - agent_holdings = {tkn: agent.holdings[tkn] for tkn in list(agent.holdings.keys())} - liquidity_removed = {tkn: 0 for tkn in omnipool.asset_list} - lrna_removed = {tkn: 0 for tkn in omnipool.asset_list} - - for key in agent.holdings.keys(): - if isinstance(key, tuple): - tkn = key[1] - del agent_holdings[key] - - if agent.holdings[key] > 0: - delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l = omnipool.calculate_remove_liquidity( - agent, - agent.holdings[key], - tkn_remove=tkn - ) - agent_holdings['LRNA'] += delta_qa - if tkn not in agent_holdings: - agent_holdings[tkn] = 0 - agent_holdings[tkn] -= delta_r - liquidity_removed[tkn] -= delta_r - lrna_removed[tkn] -= delta_q - - if 'LRNA' not in prices and agent_holdings['LRNA'] > 0: + delta_qa, delta_r, delta_q, delta_s, delta_b, delta_l = 0, {}, {}, {}, {}, 0 + nft_ids = [] + + for k in agent.holdings: + if isinstance(k, tuple) and len(k) == 2 and k[0] == omnipool.unique_id: # LP shares of correct pool + tkn = k[1] + dqa, dr, dq, ds, db, dl, ids = omnipool.calculate_remove_liquidity(agent, tkn_remove=tkn) + delta_qa += dqa + delta_r[tkn] = dr + (delta_r[tkn] if tkn in delta_r else 0) + delta_q[tkn] = dq + (delta_q[tkn] if tkn in delta_q else 0) + delta_s[tkn] = ds + (delta_s[tkn] if tkn in delta_s else 0) + delta_b[tkn] = db + (delta_b[tkn] if tkn in delta_b else 0) + delta_l += dl + nft_ids += ids + + for nft_id in agent.nfts: + if nft_id not in nft_ids and agent.nfts[nft_id].pool_id == omnipool.unique_id: + tkn = agent.nfts[nft_id].tkn + dqa, dr, dq, ds, db, dl, _ = omnipool.calculate_remove_liquidity(agent, nft_id=nft_id) + delta_qa += dqa + delta_r[tkn] = dr + (delta_r[tkn] if tkn in delta_r else 0) + delta_q[tkn] = dq + (delta_q[tkn] if tkn in delta_q else 0) + delta_s[tkn] = ds + (delta_s[tkn] if tkn in delta_s else 0) + delta_b[tkn] = db + (delta_b[tkn] if tkn in delta_b else 0) + delta_l += dl + + # agent_holdings = new_agent.holdings + lrna_removed = {tkn: -delta_q[tkn] if tkn in delta_q else 0 for tkn in omnipool.asset_list} + liquidity_removed = {tkn: -delta_r[tkn] if tkn in delta_r else 0 for tkn in omnipool.asset_list} + + # if 'LRNA' in prices: + # raise ValueError('LRNA price should not be given.') + agent_lrna = delta_qa + if 'LRNA' in agent.holdings: + agent_lrna += agent.holdings['LRNA'] + + if 'LRNA' not in prices and agent_lrna > 0: lrna_total = omnipool.lrna_total - sum(lrna_removed.values()) lrna_sells = { - tkn: -(omnipool.lrna[tkn] - lrna_removed[tkn]) / lrna_total * agent_holdings['LRNA'] + tkn: -(omnipool.lrna[tkn] - lrna_removed[tkn]) / lrna_total * agent_lrna for tkn in omnipool.asset_list } - agent_holdings['LRNA'] = 0 lrna_profits = dict() # sell LRNA optimally back to the pool for tkn, delta_qa in lrna_sells.items(): - if tkn not in agent_holdings: - agent_holdings[tkn] = 0 asset_fee = ( omnipool.asset_fee[tkn].compute() if hasattr(omnipool, 'asset_fee') @@ -1474,12 +1610,13 @@ def cash_out_omnipool(omnipool: OmnipoolState, agent: Agent, prices) -> float: / (-delta_qa + omnipool.lrna[tkn] - lrna_removed[tkn]) * (1 - asset_fee) ) - agent_holdings[tkn] += lrna_profits[tkn] - - del agent_holdings['LRNA'] + liquidity_removed[tkn] += lrna_profits[tkn] - for tkn in agent_holdings.keys(): - if agent_holdings[tkn] > 0 and tkn not in prices: - raise ValueError(f'Agent has holdings in {tkn} but no price was given.') + new_holdings = {tkn: agent.holdings[tkn] for tkn in agent.holdings} + new_holdings['LRNA'] = agent_lrna + for tkn in liquidity_removed: + if tkn not in new_holdings: + new_holdings[tkn] = 0 + new_holdings[tkn] += liquidity_removed[tkn] - return value_assets(prices, agent_holdings) + return value_assets(prices, new_holdings) diff --git a/hydradx/model/amm/trade_strategies.py b/hydradx/model/amm/trade_strategies.py index 7065b111..fa999ce0 100644 --- a/hydradx/model/amm/trade_strategies.py +++ b/hydradx/model/amm/trade_strategies.py @@ -1031,26 +1031,13 @@ def strategy(state: GlobalState, agent_id: str) -> GlobalState: sell_quantity=agent.holdings[sell_asset] - init_sell_amt ) - # we turn off the withdrawal fee for this remove_liquidity - # this is because removing liquidity here is due to a limitation in our implementation, - # agents can have only one LP position each in one asset in Omnipool. - # In reality someone executing this strategy would simply add liquidity and get a new LP position. - withdrawal_fee_cache = pool.withdrawal_fee # hack to temporarily zero out withdrawal fee - pool.withdrawal_fee = False - if agent.is_holding((pool.unique_id, buy_asset)): - # remove all liquidity in buy_asset - pool.remove_liquidity( - agent=agent, - quantity=agent.holdings[(pool.unique_id, buy_asset)], - tkn_remove=buy_asset - ) - pool.withdrawal_fee = withdrawal_fee_cache - # LP the buy asset + nft_id = str(len(agent.nfts) + 1) pool.add_liquidity( agent=agent, quantity=agent.holdings[buy_asset] - init_buy_amt, - tkn_add=buy_asset + tkn_add=buy_asset, + nft_id=nft_id ) return state diff --git a/hydradx/tests/test_liquidations.py b/hydradx/tests/test_liquidations.py index a7abe9f2..cc3a3892 100644 --- a/hydradx/tests/test_liquidations.py +++ b/hydradx/tests/test_liquidations.py @@ -713,7 +713,7 @@ def test_liquidate_against_omnipool_fuzz(collateral_amt1: float, ratio1: float, if cdp1.debt_amt == 0: # fully liquidated assert ratio1 >= full_liq_threshold elif cdp1.collateral_amt == 0: # fully liquidated, bad debt remaining - assert ratio1 > 1 + assert ratio1 > 1/(1 + mm.liquidation_penalty['DOT']) elif cdp1.debt_amt == debt_amt1: # not liquidated if ratio1 < liq_threshold: # 1. overcollateralized pass diff --git a/hydradx/tests/test_omnipool_agents.py b/hydradx/tests/test_omnipool_agents.py index 7de3141c..cb409de7 100644 --- a/hydradx/tests/test_omnipool_agents.py +++ b/hydradx/tests/test_omnipool_agents.py @@ -238,10 +238,8 @@ def test_dca_with_lping( strategy.execute(state, trader_id) if init_sell_tkn_lped > 0: - if ('omnipool', buy_tkn) not in agent.holdings: + if len(agent.nfts) == 0: raise AssertionError('Agent does not have shares for buy_tkn.') - if agent.holdings[('omnipool', buy_tkn)] == init_buy_tkn_lped: - raise AssertionError('Agent did not receive shares for buy_tkn.') lp_diff = init_sell_tkn_lped if ('omnipool', sell_tkn) in agent.holdings: diff --git a/hydradx/tests/test_omnipool_amm.py b/hydradx/tests/test_omnipool_amm.py index 86264d1c..a17d569b 100644 --- a/hydradx/tests/test_omnipool_amm.py +++ b/hydradx/tests/test_omnipool_amm.py @@ -9,7 +9,8 @@ from hydradx.model.amm import omnipool_amm as oamm from hydradx.model.amm.agents import Agent from hydradx.model.amm.global_state import GlobalState -from hydradx.model.amm.omnipool_amm import price, dynamicadd_asset_fee, dynamicadd_lrna_fee, OmnipoolState +from hydradx.model.amm.omnipool_amm import price, dynamicadd_asset_fee, dynamicadd_lrna_fee, OmnipoolState, \ + cash_out_omnipool, OmnipoolLiquidityPosition from hydradx.model.amm.trade_strategies import constant_swaps, omnipool_arbitrage from hydradx.tests.strategies_omnipool import omnipool_reasonable_config, omnipool_config, assets_config @@ -165,6 +166,81 @@ def test_add_liquidity_with_existing_position_fails(initial_state: oamm.Omnipool raise AssertionError(f'Adding liquidity to an existing position should fail.') +@settings(max_examples=1) +@given(omnipool_config(token_count=3, lrna_fee=0, asset_fee=0)) +def test_add_liquidity_with_existing_position_succeeds(initial_state: oamm.OmnipoolState): + old_state = initial_state + tkn = old_state.asset_list[0] + old_agent = Agent( + holdings={tkn: old_state.liquidity[tkn], (old_state.unique_id, tkn): old_state.shares[tkn] / 10} + ) + + delta_R = old_agent.holdings[tkn] / 10 + + nft_id = "first_position" + new_state, new_agent = oamm.simulate_add_liquidity(old_state, old_agent, delta_R, tkn, nft_id) + + if new_state.fail: + raise AssertionError(f'Adding liquidity to an existing position should not fail.') + if nft_id not in new_agent.nfts: + raise AssertionError(f'LP position not added to agent NFTs.') + if len(new_agent.holdings) != len(old_agent.holdings): + raise AssertionError(f'Agent holdings have wrong length.') + + nft_id2 = "second_position" + new_state2, new_agent2 = oamm.simulate_add_liquidity(new_state, new_agent, delta_R, tkn, nft_id2) + if new_state2.fail: + raise AssertionError(f'Adding liquidity to an existing position should not fail.') + if nft_id2 not in new_agent2.nfts: + raise AssertionError(f'LP position not added to agent NFTs.') + if len(new_agent2.holdings) != len(old_agent.holdings): + raise AssertionError(f'Agent holdings have wrong length.') + + +@given(st.integers(min_value=1, max_value=10)) +def test_compare_several_lp_adds_to_single(n): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + } + ) + tkn = initial_state.asset_list[0] + init_agent = Agent(holdings={tkn: initial_state.liquidity[tkn]}) + delta_R = init_agent.holdings[tkn] / 2 + + single_add_state_1, single_add_agent_1 = oamm.simulate_add_liquidity(initial_state, init_agent, delta_R, tkn) + single_add_state_2, single_add_agent_2 = oamm.simulate_add_liquidity(initial_state, init_agent, delta_R, tkn, + nft_id="id001") + multi_add_state, multi_add_agent = initial_state, init_agent + for i in range(n): + nft_id = str(i) + multi_add_state, multi_add_agent = oamm.simulate_add_liquidity(multi_add_state, multi_add_agent, delta_R / n, + tkn, nft_id=nft_id) + + if single_add_state_1.liquidity[tkn] != pytest.approx(multi_add_state.liquidity[tkn], rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + if single_add_state_1.shares[tkn] != pytest.approx(multi_add_state.shares[tkn], rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + if single_add_state_1.lrna[tkn] != pytest.approx(multi_add_state.lrna[tkn], rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + total_multi_shares = sum([multi_add_agent.nfts[nft_id].shares for nft_id in multi_add_agent.nfts]) + shares_1 = single_add_agent_1.holdings[(single_add_state_1.unique_id, tkn)] + if total_multi_shares != pytest.approx(shares_1, rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + + if single_add_state_2.liquidity[tkn] != pytest.approx(multi_add_state.liquidity[tkn], rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + if single_add_state_2.shares[tkn] != pytest.approx(multi_add_state.shares[tkn], rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + if single_add_state_2.lrna[tkn] != pytest.approx(multi_add_state.lrna[tkn], rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + shares_2 = single_add_agent_2.nfts["id001"].shares + if shares_1 != pytest.approx(shares_2, rel=1e-20): + raise AssertionError(f'Adding liquidity in one go should be equivalent to adding it in {n} steps.') + + @settings(max_examples=1) @given(omnipool_config(token_count=3, lrna_fee=0, asset_fee=0)) def test_add_liquidity_with_quantity_zero_should_fail(initial_state: oamm.OmnipoolState): @@ -182,6 +258,194 @@ def test_add_liquidity_with_quantity_zero_should_fail(initial_state: oamm.Omnipo raise AssertionError(f'Adding liquidity with quantity zero should fail.') +def test_remove_liquidity_exact(): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + } + ) + tkn = 'DOT' + + p = initial_state.price(initial_state, tkn, 'LRNA') + s = initial_state.shares[tkn] / 10 + expected_r = initial_state.liquidity[tkn] / 10 * (1 - initial_state.min_withdrawal_fee) + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + init_agent = Agent(nfts={'position': position}) + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, s, tkn, 'position') + delta_r = initial_state.liquidity[tkn] - new_state.liquidity[tkn] + if delta_r != pytest.approx(expected_r, rel=1e-20): + raise AssertionError(f'Removed liquidity should be equal to initial liquidity minus final liquidity.') + + p = initial_state.price(initial_state, tkn, 'LRNA') / 2 + s = initial_state.shares[tkn] / 10 + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + init_agent = Agent(nfts={'position': position}) + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, s, tkn, 'position') + + expected_dq_pct = mpf(1) / 10 * (1 - initial_state.min_withdrawal_fee) + expected_agent_dq_pct = mpf(1) / 30 * (1 - initial_state.min_withdrawal_fee) + expected_dr_pct = mpf(1) / 10 * (1 - initial_state.min_withdrawal_fee) + expected_ds_pct = mpf(1) / 10 + + actual_dq_pct = (initial_state.lrna[tkn] - new_state.lrna[tkn]) / initial_state.lrna[tkn] + actual_agent_dq_pct = new_agent.holdings['LRNA'] / initial_state.lrna[tkn] + actual_dr_pct = (initial_state.liquidity[tkn] - new_state.liquidity[tkn]) / initial_state.liquidity[tkn] + actual_ds_pct = (initial_state.shares[tkn] - new_state.shares[tkn]) / initial_state.shares[tkn] + actual_db = initial_state.protocol_shares[tkn] - new_state.protocol_shares[tkn] + + if actual_dq_pct != pytest.approx(expected_dq_pct, rel=1e-20): + raise AssertionError(f'LRNA change incorrect') + if actual_agent_dq_pct != pytest.approx(expected_agent_dq_pct, rel=1e-20): + raise AssertionError(f'LRNA given to agent incorrect') + if actual_dr_pct != pytest.approx(expected_dr_pct, rel=1e-20): + raise AssertionError(f'Liquidity change incorrect') + if actual_ds_pct != pytest.approx(expected_ds_pct, rel=1e-20): + raise AssertionError(f'Shares change incorrect') + if actual_db != 0: + raise AssertionError(f'Protocol should not earn shares') + + p = initial_state.price(initial_state, tkn, 'LRNA') * 2 + s = initial_state.shares[tkn] / 10 + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + init_agent = Agent(nfts={'position': position}) + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, s, tkn, 'position') + + expected_dq_pct = mpf(2) / 30 * (1 - initial_state.min_withdrawal_fee) + expected_dr_pct = mpf(2) / 30 * (1 - initial_state.min_withdrawal_fee) + expected_ds_pct = mpf(2) / 30 + expected_db_pct = mpf(1) / 30 + + actual_dq_pct = (initial_state.lrna[tkn] - new_state.lrna[tkn]) / initial_state.lrna[tkn] + actual_agent_dq = new_agent.holdings['LRNA'] if 'LRNA' in new_agent.holdings else 0 + actual_dr_pct = (initial_state.liquidity[tkn] - new_state.liquidity[tkn]) / initial_state.liquidity[tkn] + actual_ds_pct = (initial_state.shares[tkn] - new_state.shares[tkn]) / initial_state.shares[tkn] + actual_db_pct = (new_state.protocol_shares[tkn] - initial_state.protocol_shares[tkn]) / initial_state.shares[tkn] + + if actual_dq_pct != pytest.approx(expected_dq_pct, rel=1e-20): + raise AssertionError(f'LRNA change incorrect') + if actual_agent_dq != pytest.approx(0, rel=1e-20): + raise AssertionError(f'LRNA given to agent incorrect') + if actual_dr_pct != pytest.approx(expected_dr_pct, rel=1e-20): + raise AssertionError(f'Liquidity change incorrect') + if actual_ds_pct != pytest.approx(expected_ds_pct, rel=1e-20): + raise AssertionError(f'Shares change incorrect') + if actual_db_pct != pytest.approx(expected_db_pct, rel=1e-20): + raise AssertionError(f'Protocol shares incorrect') + + +@given(st.floats(min_value=0.1, max_value=10)) +def test_remove_liquidity_specified_quantity_unspecified_nft(price_mult: float): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + } + ) + tkn = 'DOT' + + p = price_mult * initial_state.price(initial_state, tkn, 'LRNA') + s = initial_state.shares[tkn] / 10 + holdings = {(initial_state.unique_id, tkn): s} + share_prices = {(initial_state.unique_id, tkn): p} + init_agent = Agent(holdings=holdings, share_prices=share_prices) + + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + base_agent = Agent(nfts={'position': position}) + + quantity = s + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, quantity, tkn) + comp_state, comp_agent = oamm.simulate_remove_liquidity(initial_state, base_agent, quantity, tkn, 'position') + if new_state.liquidity[tkn] != pytest.approx(comp_state.liquidity[tkn], rel=1e-20): + raise AssertionError(f'Remaining liquidity doesn\'t match.') + if new_state.shares[tkn] != pytest.approx(comp_state.shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining shares doesn\'t match.') + if new_state.lrna[tkn] != pytest.approx(comp_state.lrna[tkn], rel=1e-20): + raise AssertionError(f'Remaining LRNA doesn\'t match.') + if new_state.protocol_shares[tkn] != pytest.approx(comp_state.protocol_shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining protocol shares doesn\'t match.') + + quantity = s / 2 + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, quantity, tkn) + comp_state, comp_agent = oamm.simulate_remove_liquidity(initial_state, base_agent, quantity, tkn, 'position') + if new_state.liquidity[tkn] != pytest.approx(comp_state.liquidity[tkn], rel=1e-20): + raise AssertionError(f'Remaining liquidity doesn\'t match.') + if new_state.shares[tkn] != pytest.approx(comp_state.shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining shares doesn\'t match.') + if new_state.lrna[tkn] != pytest.approx(comp_state.lrna[tkn], rel=1e-20): + raise AssertionError(f'Remaining LRNA doesn\'t match.') + if new_state.protocol_shares[tkn] != pytest.approx(comp_state.protocol_shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining protocol shares doesn\'t match.') + + quantity = s * 2 + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, quantity, tkn) + if not new_state.fail: + raise AssertionError(f'Removing liquidity with quantity greater than holdings should fail.') + + +@given(st.floats(min_value=0.1, max_value=10)) +def test_remove_liquidity_unspecified_quantity_specified_nft(price_mult: float): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + } + ) + tkn = 'DOT' + + p = price_mult * initial_state.price(initial_state, tkn, 'LRNA') + s = initial_state.shares[tkn] / 10 + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + init_agent = Agent(nfts={'position': position}) + + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, tkn_remove=tkn, nft_id='position') + comp_state, comp_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, s, tkn, 'position') + if new_state.liquidity[tkn] != pytest.approx(comp_state.liquidity[tkn], rel=1e-20): + raise AssertionError(f'Remaining liquidity doesn\'t match.') + if new_state.shares[tkn] != pytest.approx(comp_state.shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining shares doesn\'t match.') + if new_state.lrna[tkn] != pytest.approx(comp_state.lrna[tkn], rel=1e-20): + raise AssertionError(f'Remaining LRNA doesn\'t match.') + if new_state.protocol_shares[tkn] != pytest.approx(comp_state.protocol_shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining protocol shares doesn\'t match.') + + +@given(st.floats(min_value=0.1, max_value=10)) +def test_remove_liquidity_unspecified_quantity_unspecified_nft(price_mult: float): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + } + ) + tkn = 'DOT' + + p = price_mult * initial_state.price(initial_state, tkn, 'LRNA') + s = initial_state.shares[tkn] / 10 + holdings = {(initial_state.unique_id, tkn): s / 2} + share_prices = {(initial_state.unique_id, tkn): p} + position = OmnipoolLiquidityPosition(tkn, p, s / 2, 0, initial_state.unique_id) + init_agent = Agent(holdings=holdings, share_prices=share_prices, nfts={'position': position}) + + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + base_agent = Agent(nfts={'position': position}) + + new_state, new_agent = oamm.simulate_remove_liquidity(initial_state, init_agent, tkn_remove=tkn) + comp_state, comp_agent = oamm.simulate_remove_liquidity(initial_state, base_agent, s, tkn, 'position') + if new_state.liquidity[tkn] != pytest.approx(comp_state.liquidity[tkn], rel=1e-20): + raise AssertionError(f'Remaining liquidity doesn\'t match.') + if new_state.shares[tkn] != pytest.approx(comp_state.shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining shares doesn\'t match.') + if new_state.lrna[tkn] != pytest.approx(comp_state.lrna[tkn], rel=1e-20): + raise AssertionError(f'Remaining LRNA doesn\'t match.') + if new_state.protocol_shares[tkn] != pytest.approx(comp_state.protocol_shares[tkn], rel=1e-20): + raise AssertionError(f'Remaining protocol shares doesn\'t match.') + + @given(omnipool_config(token_count=3, withdrawal_fee=False)) def test_remove_liquidity_no_fee(initial_state: oamm.OmnipoolState): i = initial_state.asset_list[2] @@ -356,6 +620,47 @@ def test_remove_liquidity_no_fee_different_price(initial_state: oamm.OmnipoolSta raise AssertionError(f'LRNA imbalance did not remain constant.') +@given(st.floats(min_value=1, max_value=100), + st.floats(min_value=0.1, max_value=0.9)) +def test_remove_liquidity_split(price: float, split: float): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + }, + withdrawal_fee=False + ) + tkn = 'DOT' + amt1 = initial_state.shares[tkn] / 5 + amt2 = amt1 * split + amt3 = amt1 - amt2 + holdings1 = {(initial_state.unique_id, tkn): amt1} + prices1 = {(initial_state.unique_id, tkn): price} + agent1 = Agent(holdings=holdings1, share_prices=prices1) + + nft1 = OmnipoolLiquidityPosition(tkn, price, amt2, 0, initial_state.unique_id) + nft2 = OmnipoolLiquidityPosition(tkn, price, amt3, 0, initial_state.unique_id) + nfts = {'nft001': nft1, 'nft002': nft2} + agent2 = Agent(nfts=nfts) + + state1 = initial_state.copy() + state2 = initial_state.copy() + state1.remove_liquidity(agent1, tkn_remove=tkn) + state2.remove_liquidity(agent2, tkn_remove=tkn) + + if state1.liquidity[tkn] != pytest.approx(state2.liquidity[tkn], rel=1e-20): + raise AssertionError('liquidity should match') + if state1.shares[tkn] != pytest.approx(state2.shares[tkn], rel=1e-20): + raise AssertionError('shares should match') + if agent1.holdings[tkn] != pytest.approx(agent2.holdings[tkn], rel=1e-20): + raise AssertionError('holdings should match') + if agent1.holdings[(initial_state.unique_id, tkn)] != 0: + raise AssertionError('holdings of shares should be zero') + if len(agent2.nfts) != 0: + raise AssertionError('LP positions should be removed') + + @given(omnipool_config(token_count=3)) def test_swap_lrna(initial_state: oamm.OmnipoolState): old_state = initial_state @@ -1365,7 +1670,6 @@ def test_dynamic_fees_with_trade(liquidity: list[float], lrna: list[float], orac oracle_volume_in: list[float], oracle_volume_out: list[float], oracle_prices: list[float], n, trade_size: float, lrna_fees: list[float], asset_fees: list[float], amp: list[float], decay: list[float]): - init_liquidity = { 'HDX': {'liquidity': liquidity[0], 'LRNA': lrna[0]}, 'USD': {'liquidity': liquidity[1], 'LRNA': lrna[1]}, @@ -2458,7 +2762,8 @@ def test_calculate_buy_from_sell(omnipool: oamm.OmnipoolState): usd_lrna_fee=st.floats(min_value=0, max_value=0.1) ) def test_buy_sell_spot( - hdx_lrna: float, usd_lrna: float, hdx_asset_fee: float, hdx_lrna_fee: float, usd_asset_fee: float, usd_lrna_fee: float + hdx_lrna: float, usd_lrna: float, hdx_asset_fee: float, hdx_lrna_fee: float, usd_asset_fee: float, + usd_lrna_fee: float ): tokens = { 'HDX': {'liquidity': mpf(1000000000), 'LRNA': hdx_lrna}, @@ -2669,3 +2974,176 @@ def test_fee_application(): buy_quantity_2 = sell_lrna_agent.holdings['USD'] if buy_quantity != pytest.approx(buy_quantity_2, rel=1e-12): raise AssertionError("Direct swap was not equivalent to LRNA swap with fees applied manually.") + + +def test_cash_out_omnipool_exact(): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + } + ) + tkn = 'DOT' + + p = initial_state.price(initial_state, tkn, 'LRNA') + s = initial_state.shares[tkn] / 10 + prices = {tkn: initial_state.price(initial_state, tkn, 'USD') for tkn in initial_state.asset_list} + expected_r = initial_state.liquidity[tkn] / 10 * (1 - initial_state.min_withdrawal_fee) + expected_cash = expected_r * prices[tkn] + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + init_agent = Agent(nfts={'position': position}) + cash = cash_out_omnipool(initial_state, init_agent, prices) + if cash != pytest.approx(expected_cash, rel=1e-20): + raise AssertionError(f'Removed liquidity should be equal to initial liquidity minus final liquidity.') + + p = initial_state.price(initial_state, tkn, 'LRNA') / 2 + s = initial_state.shares[tkn] / 10 + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + init_agent = Agent(nfts={'position': position}) + cash = cash_out_omnipool(initial_state, init_agent, prices) + + expected_agent_dq_pct = mpf(1) / 30 * (1 - initial_state.min_withdrawal_fee) + expected_agent_dq = expected_agent_dq_pct * initial_state.lrna[tkn] + + expected_dr_pct = mpf(1) / 10 * (1 - initial_state.min_withdrawal_fee) + expected_dr = expected_dr_pct * initial_state.liquidity[tkn] + expected_min_cash = expected_dr * prices[tkn] + expected_max_cash = expected_min_cash + expected_agent_dq + initial_state.price(initial_state, 'LRNA', 'USD') + expected_cash_out_lrna = 32954 + + if expected_agent_dq <= 0: + raise AssertionError(f'LRNA change incorrect') + if cash <= expected_min_cash: + raise AssertionError(f'Cash out should be at least the minimum amount') + if cash >= expected_max_cash: + raise AssertionError(f'Cash out should be at most the maximum amount') + if cash - expected_min_cash != pytest.approx(expected_cash_out_lrna, rel=1e-4): + raise AssertionError(f'cash incorrect') + + p = initial_state.price(initial_state, tkn, 'LRNA') * 2 + s = initial_state.shares[tkn] / 10 + position = OmnipoolLiquidityPosition(tkn, p, s, 0, initial_state.unique_id) + init_agent = Agent(nfts={'position': position}) + cash = cash_out_omnipool(initial_state, init_agent, prices) + + expected_dr_pct = mpf(2) / 30 * (1 - initial_state.min_withdrawal_fee) + expected_dr = expected_dr_pct * initial_state.liquidity[tkn] + expected_cash = expected_dr * prices[tkn] + + if cash != pytest.approx(expected_cash, rel=1e-20): + raise AssertionError(f'cash incorrect') + + +@given(st.floats(min_value=10.1, max_value=100)) +def test_cash_out_nft_position(price1: float): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + }, + withdrawal_fee=False + ) + dot_spot_price = initial_state.price(initial_state, 'DOT', 'USD') + tkn = 'DOT' + amt1 = initial_state.shares[tkn] / 5 + delta_r = initial_state.liquidity[tkn] / 5 + nft = OmnipoolLiquidityPosition(tkn, price1, amt1, delta_r, initial_state.unique_id) + agent = Agent(holdings={}, nfts={'pos1': nft}) + cash_out = cash_out_omnipool(initial_state, agent, {'DOT': dot_spot_price}) + + state = initial_state.copy() + state.remove_liquidity(agent, tkn_remove=tkn) + dot_value = agent.holdings['DOT'] * dot_spot_price + assert cash_out == pytest.approx(dot_value, rel=1e-20) + + +@given(st.floats(min_value=10.1, max_value=100), + st.floats(min_value=10.1, max_value=100), + st.floats(min_value=0.1, max_value=0.9)) +def test_cash_out_nft_position_with_holdings(price1: float, price2: float, r: float): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + }, + withdrawal_fee=False + ) + dot_spot_price = initial_state.price(initial_state, 'DOT', 'USD') + tkn = 'DOT' + amt1 = r * initial_state.shares[tkn] / 5 + amt2 = initial_state.shares[tkn] / 5 - amt1 + holdings1 = {(initial_state.unique_id, tkn): amt1} + prices1 = {(initial_state.unique_id, tkn): price1} + nft = OmnipoolLiquidityPosition(tkn, price2, amt2, 0, initial_state.unique_id) + agent = Agent(holdings=holdings1, share_prices=prices1, nfts={'pos1': nft}) + cash_out = cash_out_omnipool(initial_state, agent, {'DOT': dot_spot_price}) + + state = initial_state.copy() + state.remove_liquidity(agent, tkn_remove=tkn) + dot_value = agent.holdings['DOT'] * dot_spot_price + assert cash_out == pytest.approx(dot_value, rel=1e-20) + + +@given(st.floats(min_value=0.1, max_value=9.9), + st.floats(min_value=0.1, max_value=9.9), + st.floats(min_value=0.1, max_value=0.9)) +def test_cash_out_multiple_positions_works_with_lrna(price1: float, price2: float, r: float): + liquidity = {'HDX': mpf(10000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + }, + withdrawal_fee=False + ) + tkn = 'DOT' + amt1 = r * initial_state.shares[tkn] / 5 + amt2 = initial_state.shares[tkn] / 5 - amt1 + holdings1 = {(initial_state.unique_id, tkn): amt1} + prices1 = {(initial_state.unique_id, tkn): price1} + nft = OmnipoolLiquidityPosition(tkn, price2, amt2, 0, initial_state.unique_id) + agent = Agent(holdings=holdings1, share_prices=prices1, nfts={'pos1': nft}) + spot_prices = {tkn: initial_state.price(initial_state, tkn, 'USD') for tkn in initial_state.asset_list} + cash_out = cash_out_omnipool(initial_state, agent, spot_prices) + + state = initial_state.copy() + state.remove_liquidity(agent, tkn_remove=tkn) + dot_value = agent.holdings['DOT'] * spot_prices['DOT'] + lrna_value = agent.holdings['LRNA'] * initial_state.price(initial_state, 'LRNA', 'USD') + assert dot_value < cash_out < dot_value + lrna_value # cash_out will be less than dot + lrna due to slippage + + +@given(st.lists(st.floats(min_value=-100000, max_value=100000), min_size=3, max_size=3)) +def test_cash_out_multiple_positions(trade_sizes: list[float]): + liquidity = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(100000)} + lrna = {'HDX': mpf(1000000), 'USD': mpf(1000000), 'DOT': mpf(1000000)} + initial_state = oamm.OmnipoolState( + tokens={ + tkn: {'liquidity': liquidity[tkn], 'LRNA': lrna[tkn]} for tkn in lrna + }, + withdrawal_fee=False + ) + + lp_quantity = mpf(10000) + agent1 = Agent(holdings={'DOT': lp_quantity * len(trade_sizes)}) + agent2 = Agent(holdings={'DOT': mpf(10000000), 'HDX': mpf(10000000)}) + for i, trade in enumerate(trade_sizes): + initial_state.add_liquidity(agent1, tkn_add='DOT', quantity=lp_quantity, nft_id=str(i)) + if trade > 0: + initial_state.swap(agent2, tkn_buy='HDX', tkn_sell='DOT', sell_quantity=mpf(trade)) + elif trade < 0: + initial_state.swap(agent2, tkn_buy='DOT', tkn_sell='HDX', sell_quantity=-mpf(trade)) + + spot_prices = {tkn: initial_state.price(initial_state, tkn, 'USD') for tkn in initial_state.asset_list} + spot_prices['LRNA'] = oamm.usd_price(initial_state, 'LRNA') + + cash_out_value = cash_out_omnipool(initial_state, agent1, spot_prices) + cash_out_state = initial_state.copy() + cash_out_agent = agent1.copy() + cash_out_state.remove_liquidity(cash_out_agent, tkn_remove='DOT') + reference_value = oamm.value_assets(spot_prices, cash_out_agent.holdings) + if cash_out_value != pytest.approx(reference_value, 1e-20): + raise AssertionError("Cash out not computed correctly.") diff --git a/hydradx/tests/test_omnipool_state.py b/hydradx/tests/test_omnipool_state.py index aed0aa52..40b1c8e2 100644 --- a/hydradx/tests/test_omnipool_state.py +++ b/hydradx/tests/test_omnipool_state.py @@ -311,7 +311,7 @@ def test_cash_out_accuracy(omnipool: oamm.OmnipoolState, share_price_ratio, lp_i ) lrna_profits[tkn] = withdraw_agent.holdings[tkn] - agent_holdings - del withdraw_agent.holdings['LRNA'] + del withdraw_agent.holdings['LRNA'] cash_count = sum([market_prices[tkn] * withdraw_agent.holdings[tkn] for tkn in withdraw_agent.holdings]) if cash_count != pytest.approx(cash_out, rel=1e-15): raise AssertionError('Cash out calculation is not accurate.') diff --git a/hydradx/tests/test_stableswap.py b/hydradx/tests/test_stableswap.py index 4fe30c06..d46388e5 100644 --- a/hydradx/tests/test_stableswap.py +++ b/hydradx/tests/test_stableswap.py @@ -459,6 +459,7 @@ def test_exploitability(initial_lp: int, trade_size: int): break +@settings(deadline=timedelta(milliseconds=500)) @given( st.integers(min_value=1, max_value=1000000), st.floats(min_value=0.00001, max_value=0.99999)