From 9a000dbd6be40ff0560cd757b30495f7eba167d5 Mon Sep 17 00:00:00 2001 From: nagakingg <82352736+nagakingg@users.noreply.github.com> Date: Tue, 22 Aug 2023 16:06:07 -0500 Subject: [PATCH] Add cryptoswap liquidity density metric - Update LD estimate for robustness across pool types - Use pool.get_min_trade_size() for cryptoswap LD estimate - Add virtual price and block timestamp to cryptoswap state log - Reorganize PriceDepth metric to minimize duplication --- ...nagakingg_cryptoswap_liquidity_density.rst | 12 ++ curvesim/metrics/metrics.py | 127 +++++++++------- curvesim/metrics/state_log/pool_state.py | 2 + curvesim/pipelines/common/__init__.py | 2 +- test/fixtures/sim_pools.py | 5 + test/unit/test_metrics_price_depth.py | 138 ++++++++++++++++++ test/unit/test_state_log.py | 2 + 7 files changed, 237 insertions(+), 51 deletions(-) create mode 100644 changelog.d/20230823_141423_nagakingg_cryptoswap_liquidity_density.rst create mode 100644 test/unit/test_metrics_price_depth.py diff --git a/changelog.d/20230823_141423_nagakingg_cryptoswap_liquidity_density.rst b/changelog.d/20230823_141423_nagakingg_cryptoswap_liquidity_density.rst new file mode 100644 index 000000000..c1642debd --- /dev/null +++ b/changelog.d/20230823_141423_nagakingg_cryptoswap_liquidity_density.rst @@ -0,0 +1,12 @@ + +Added +----- + +- Added PriceDepth (liquidity density) metric for Cryptoswap pools +- Added virtual price and block timestamp to get_cryptoswap_pool_state() +- Added detailed testing of liquidity density calculations + +Changed +------- + +- Improved liquidity density calculation for robustness across pool types diff --git a/curvesim/metrics/metrics.py b/curvesim/metrics/metrics.py index bb79ce5e9..9e421f9f7 100644 --- a/curvesim/metrics/metrics.py +++ b/curvesim/metrics/metrics.py @@ -15,7 +15,7 @@ from copy import deepcopy from altair import Axis, Scale -from numpy import array, exp, log, timedelta64 +from numpy import array, exp, inf, log, timedelta64 from pandas import DataFrame, concat from curvesim.pool.sim_interface import ( @@ -522,14 +522,11 @@ class PriceDepth(PoolMetric): liquidity density, % change in reserves per % change in price. """ - __slots__ = ["_factor"] - @property @cache def pool_config(self): - ss_config = { + base = { "functions": { - "metrics": self.get_curve_LD, "summary": {"liquidity_density": ["median", "min"]}, }, "plot": { @@ -552,73 +549,103 @@ def pool_config(self): }, } - return dict.fromkeys( - [SimCurveMetaPool, SimCurvePool, SimCurveRaiPool, SimCurveCryptoPool], - ss_config, - ) + functions = { + SimCurvePool: self.get_stableswap_LD, + SimCurveMetaPool: self.get_stableswap_LD, + SimCurveRaiPool: self.get_stableswap_LD, + SimCurveCryptoPool: self.get_cryptoswap_LD, + } + + config = {} + for pool, fn in functions.items(): + config[pool] = deepcopy(base) + config[pool]["functions"]["metrics"] = fn - def __init__(self, pool, factor=10**8, **kwargs): - self._factor = factor - super().__init__(pool, **kwargs) + return config - def get_curve_LD(self, **kwargs): + def get_stableswap_LD(self, **kwargs): """ Computes liquidity density for each timestamp in an individual run. - Used for all Curve pools. + Used for all Curve stableswap pools. """ pool_state = kwargs["pool_state"] - coin_pairs = get_pairs( - self._pool.coin_names - ) # for metapool, uses only meta assets - LD = pool_state.apply(self._get_curve_LD_by_row, axis=1, coin_pairs=coin_pairs) - return DataFrame(LD, columns=["liquidity_density"]) + def trade_size_function(coin_in): + x_per_dx = 10**8 + return self._pool.asset_balances[coin_in] // x_per_dx + + return self._get_LD(pool_state, trade_size_function) - def _get_curve_LD_by_row(self, pool_state_row, coin_pairs): + def get_cryptoswap_LD(self, **kwargs): """ - Computes liquidity density for a single row of data (i.e., a single timestamp). - Used for all Curve pools. + Computes liquidity density for each timestamp in an individual run. + Used for all Curve crpytoswap pools. """ - self.set_pool_state(pool_state_row) + pool_state = kwargs["pool_state"] - LD = [] - for pair in coin_pairs: - ld = self._compute_liquidity_density(*pair) - LD.append(ld) - return sum(LD) / len(LD) + extra_profit = self._pool.allowed_extra_profit # disable price_scale updates + self._pool.allowed_extra_profit = inf + + def trade_size_function(coin_in): + return self._pool.get_min_trade_size(coin_in) - def _compute_liquidity_density(self, coin_in, coin_out): + LD = self._get_LD(pool_state, trade_size_function) + self._pool.allowed_extra_profit = extra_profit + return LD + + def _get_LD(self, pool_state, trade_size_function): """ - Computes liquidity density for a single pair of coins. + Computes liquidity density for each timestamp in an individual run. + Used for any sim pool. """ - factor = self._factor - pool = self._pool - post_trade_price = self._post_trade_price + coin_pairs = get_pairs(self._pool.coin_names) # only meta assets for metapools - price_pre = pool.price(coin_in, coin_out, use_fee=False) - price_post = post_trade_price(pool, coin_in, coin_out, factor) - LD1 = price_pre / ((price_pre - price_post) * factor) - - price_pre = pool.price(coin_out, coin_in, use_fee=False) - # pylint: disable-next=arguments-out-of-order - price_post = post_trade_price(pool, coin_out, coin_in, factor) - LD2 = price_pre / ((price_pre - price_post) * factor) + LD = pool_state.apply( + self._get_LD_by_row, + axis=1, + coin_pairs=coin_pairs, + trade_size_function=trade_size_function, + ) - return (LD1 + LD2) / 2 + return DataFrame(LD, columns=["liquidity_density"]) - @staticmethod - def _post_trade_price(pool, coin_in, coin_out, factor, use_fee=False): + def _get_LD_by_row(self, pool_state_row, coin_pairs, trade_size_function): """ - Computes price after executing a trade of size coin_in balances / factor. + Computes liquidity density for a single row of data (i.e., a single timestamp). + Used for any sim pool. """ + self.set_pool_state(pool_state_row) + pool = self._pool + + LD = [] + for pair in coin_pairs: + amount_in = [trade_size_function(coin) for coin in pair] + LD_i = _compute_liquidity_density(pool, *pair, amount_in[0]) + LD_j = _compute_liquidity_density(pool, *reversed(pair), amount_in[1]) + LD += [LD_i, LD_j] + return sum(LD) / len(LD) - size = pool.asset_balances[coin_in] // factor - with pool.use_snapshot_context(): - pool.trade(coin_in, coin_out, size) - price = pool.price(coin_in, coin_out, use_fee=use_fee) +def _compute_liquidity_density(pool, coin_in, coin_out, amount_in): + """ + Computes liquidity density for a single pair of coins. + """ + x_avg = pool.asset_balances[coin_in] + amount_in / 2 + price_pre = pool.price(coin_in, coin_out, use_fee=False) + price_post = _post_trade_price(pool, coin_in, coin_out, amount_in) + LD = amount_in * (price_pre + price_post) / (2 * (price_pre - price_post) * x_avg) + return LD + + +def _post_trade_price(pool, coin_in, coin_out, amount_in, use_fee=False): + """ + Computes price after executing a trade of size amount_in. + """ + with pool.use_snapshot_context(): + pool.trade(coin_in, coin_out, amount_in) + price = pool.price(coin_in, coin_out, use_fee=use_fee) - return price + return price class Timestamp(Metric): diff --git a/curvesim/metrics/state_log/pool_state.py b/curvesim/metrics/state_log/pool_state.py index 139f21946..897a1fa3b 100644 --- a/curvesim/metrics/state_log/pool_state.py +++ b/curvesim/metrics/state_log/pool_state.py @@ -33,7 +33,9 @@ def get_cryptoswap_pool_state(pool): "xcp_profit_a": pool.xcp_profit_a, "last_prices": pool.last_prices.copy(), "last_prices_timestamp": pool.last_prices_timestamp, + "_block_timestamp": pool._block_timestamp, # pylint: disable=protected-access "not_adjusted": pool.not_adjusted, + "virtual_price": pool.virtual_price, } diff --git a/curvesim/pipelines/common/__init__.py b/curvesim/pipelines/common/__init__.py index 5b7f12d51..ce0110541 100644 --- a/curvesim/pipelines/common/__init__.py +++ b/curvesim/pipelines/common/__init__.py @@ -13,7 +13,7 @@ Metrics.Timestamp, Metrics.PoolValue, Metrics.PoolBalance, - # Metrics.PriceDepth, + Metrics.PriceDepth, Metrics.PoolVolume, Metrics.ArbMetrics, ] diff --git a/test/fixtures/sim_pools.py b/test/fixtures/sim_pools.py index f167869f4..97a55bae9 100644 --- a/test/fixtures/sim_pools.py +++ b/test/fixtures/sim_pools.py @@ -14,6 +14,11 @@ def sim_curve_pool(): return SimCurvePool(A=250, D=1000000 * 10**18, n=2, admin_fee=5 * 10**9) +@pytest.fixture(scope="function") +def sim_curve_tripool(): + return SimCurvePool(A=250, D=1000000 * 10**18, n=3, admin_fee=5 * 10**9) + + @pytest.fixture(scope="function") def sim_curve_meta_pool(): basepool = SimCurvePool(A=250, D=1000000 * 10**18, n=2, admin_fee=5 * 10**9) diff --git a/test/unit/test_metrics_price_depth.py b/test/unit/test_metrics_price_depth.py new file mode 100644 index 000000000..d469148a5 --- /dev/null +++ b/test/unit/test_metrics_price_depth.py @@ -0,0 +1,138 @@ +from numpy import inf, mean + +from curvesim.metrics.metrics import _compute_liquidity_density +from curvesim.utils import get_pairs + + +def test_liquidity_density_sim_curve_pool(sim_curve_pool): + _test_liquidity_density_stableswap(sim_curve_pool) + + +def test_liquidity_density_sim_curve_tripool(sim_curve_tripool): + _test_liquidity_density_stableswap(sim_curve_tripool) + + +def test_liquidity_density_sim_curve_meta_pool(sim_curve_meta_pool): + basepool = sim_curve_meta_pool.basepool + coin_names = ["BP_SYM" + str(i) for i in range(basepool.n)] + basepool.metadata = {"coins": {"names": coin_names}} + _test_liquidity_density_stableswap(sim_curve_meta_pool) + + +def test_liquidity_density_sim_curve_crypto_pool(sim_curve_crypto_pool): + _test_liquidity_density_cryptoswap(sim_curve_crypto_pool) + + +def test_liquidity_density_sim_curve_tricrypto_pool(sim_curve_tricrypto_pool): + _test_liquidity_density_cryptoswap(sim_curve_tricrypto_pool) + + +def _test_liquidity_density_stableswap(pool): + """ + Tests liquidity density for stableswap pools: + - Ensure LD is max at center + - Ensure LDs ~equal at equivalent curve positions for every trading pair/direction + - Ensure LD at center is ~correct + """ + + def trade_size_function(pool, coin_in): + x_per_dx = 10**8 + return pool.asset_balances[coin_in] // x_per_dx + + A_list = [10, 50, 100, 500, 1000, 5000] + for A in A_list: + pool.A = A + LD_range = _test_compute_liquidity_density(pool, trade_size_function) + + # Ensure LD at center is ~correct + LD_max_expected = (A + 1) / 2 + assert abs(LD_range[0] - LD_max_expected) / LD_max_expected < 0.001 + + +def _test_liquidity_density_cryptoswap(pool): + """ + Tests liquidity density for cryptoswap pools: + - Ensure LD is max at center + - Ensure LDs ~equal at equivalent curve positions for every trading pair/direction + - Ensure LD at center is ~correct + - Ensure LD near tail is ~.5 (constant product LD) + """ + + def trade_size_function(pool, coin_in): + return pool.get_min_trade_size(coin_in) + + pool.allowed_extra_profit = inf # disable _tweak_price + pool.balances = pool._convert_D_to_balances(pool.D) # balance pool + n = pool.n + + A_list = [10, 50, 100, 500] + gamma_list = [10**x for x in range(13, 16)] + for A in A_list: + for gamma in gamma_list: + pool.A = A * n**n * 10000 + pool.gamma = gamma + LD_range = _test_compute_liquidity_density(pool, trade_size_function) + + # Ensure LD at center is ~correct + A_v1 = A * n ** (n - 1) + LD_max_expected = (A_v1 + 1) / 2 + assert abs(LD_range[0] - LD_max_expected) / LD_max_expected < 0.0015 + + # Ensure LD near tail is ~.5 (constant product LD) + assert 0.47 < LD_range[-1] < 0.501 + + +def _test_compute_liquidity_density(pool, trade_size_fn): + """ + Computes liquidity density ranges for each trading pair/direction and tests that: + - LD is max at center + - LDs ~equal at equivalent curve positions + """ + coin_names = ["SYM" + str(i) for i in range(pool.n)] + pool.metadata = {"coins": {"names": coin_names}} + + # Compute LD range for each trading pair/direction + coin_pairs = get_pairs(pool.coin_names) + LD_ranges = [] + for pair in coin_pairs: + coin_in, coin_out = pair + LD0 = _compute_liquidity_density_range(pool, coin_in, coin_out, trade_size_fn) + LD1 = _compute_liquidity_density_range(pool, coin_out, coin_in, trade_size_fn) + LD_ranges += [LD0, LD1] + + # Ensure LD is max at center + for LD_range in LD_ranges: + assert LD_range[0] == max(LD_range) + + # Ensure LDs ~equal at equivalent curve positions for every trading pair/direction + LD_ranges_aligned = list(zip(*LD_ranges)) + LD_ranges_means = [mean(LDs) for LDs in LD_ranges_aligned] + for LDs, LD_mean in zip(LD_ranges_aligned, LD_ranges_means): + percent_deviations = [abs(LD - LD_mean) / LD_mean for LD in LDs] + assert max(percent_deviations) < 1e-4 + + return LD_ranges_means + + +def _compute_liquidity_density_range(pool, coin_in, coin_out, trade_size_function): + """Computes liquidity density at a range of locations along the bonding curve.""" + + pre_trade_size = 0 + pre_trade_step_size = pool.asset_balances[coin_in] // 250 + + coin_out_balances = pool.asset_balances[coin_out] + coin_out_balances_limit = coin_out_balances * 0.05 + + LD_range = [] + while coin_out_balances > coin_out_balances_limit: + with pool.use_snapshot_context(): + if pre_trade_size > 0: + pool.trade(coin_in, coin_out, pre_trade_size) + LD_trade_size = trade_size_function(pool, coin_in) + LD = _compute_liquidity_density(pool, coin_in, coin_out, LD_trade_size) + coin_out_balances = pool.asset_balances[coin_out] + + LD_range.append(LD) + pre_trade_size += pre_trade_step_size + + return LD_range diff --git a/test/unit/test_state_log.py b/test/unit/test_state_log.py index ac0ef34fc..6784e7533 100644 --- a/test/unit/test_state_log.py +++ b/test/unit/test_state_log.py @@ -124,7 +124,9 @@ def test_get_pool_state_curve_crypto_pool(sim_curve_crypto_pool): "xcp_profit_a": pool.xcp_profit_a, "last_prices": pool.last_prices, "last_prices_timestamp": pool.last_prices_timestamp, + "_block_timestamp": pool._block_timestamp, "not_adjusted": pool.not_adjusted, + "virtual_price": pool.virtual_price, } state = get_pool_state(pool)