diff --git a/changelog.d/20231102_145659_nagakingg_exclude_trades.rst b/changelog.d/20231102_145659_nagakingg_exclude_trades.rst new file mode 100644 index 000000000..a30ad4aff --- /dev/null +++ b/changelog.d/20231102_145659_nagakingg_exclude_trades.rst @@ -0,0 +1,15 @@ +Added +----- +- Added ArbTrade class to templates.trader. It is a Trade object with a + target price field, and a method to change the amount_in. + +Changed +------- +- Changed arbitrage optimizers to exclude trades smaller than a + pool's minimum trade size from optimization. This reduces variability + in optimization and prevents some potential errors. + +- Changed price errors returned by Traders to dicts indicating the + trading pair for each error value. + +- Changed price errors to be normalized by target price. diff --git a/curvesim/metrics/metrics.py b/curvesim/metrics/metrics.py index 2f1f70d8c..bb79ce5e9 100644 --- a/curvesim/metrics/metrics.py +++ b/curvesim/metrics/metrics.py @@ -99,7 +99,7 @@ def compute_arb_metrics(self, **kwargs): profits = self._compute_profits(prices, trade_data.trades) price_error = trade_data.price_errors.apply( - lambda errors: sum(abs(e) for e in errors) + lambda errors: sum(abs(e) for e in errors.values()) ) results = concat([profits, price_error], axis=1) diff --git a/curvesim/pipelines/common/__init__.py b/curvesim/pipelines/common/__init__.py index 01be31fa3..5b7f12d51 100644 --- a/curvesim/pipelines/common/__init__.py +++ b/curvesim/pipelines/common/__init__.py @@ -6,6 +6,7 @@ from curvesim.logging import get_logger from curvesim.metrics import metrics as Metrics +from curvesim.templates.trader import ArbTrade logger = get_logger(__name__) DEFAULT_METRICS = [ @@ -55,25 +56,22 @@ def post_trade_price_error(dx, coin_in, coin_out, price_target): trades = [] for pair in prices: - i, j = pair - - if pool.price(i, j) - prices[pair] > 0: - price = prices[pair] - coin_in, coin_out = i, j - elif pool.price(j, i) - 1 / prices[pair] > 0: - price = 1 / prices[pair] - coin_in, coin_out = j, i - else: - trades.append((0, pair, prices[pair])) + coin_in, coin_out, target_price = _get_arb_direction(pair, pool, prices[pair]) + + lower_bound = pool.get_min_trade_size(coin_in) + profit_per_unit = post_trade_price_error( + lower_bound, coin_in, coin_out, target_price + ) + if profit_per_unit <= 0: + trades.append(ArbTrade(coin_in, coin_out, 0, target_price)) continue - high = pool.get_max_trade_size(coin_in, coin_out) - bounds = (0, high) + upper_bound = pool.get_max_trade_size(coin_in, coin_out) try: res = root_scalar( post_trade_price_error, - args=(coin_in, coin_out, price), - bracket=bounds, + args=(coin_in, coin_out, target_price), + bracket=(lower_bound, upper_bound), method="brentq", ) size = int(res.root) @@ -85,11 +83,26 @@ def post_trade_price_error(dx, coin_in, coin_out, price_target): coin_in, coin_out, pool_price, - price, - pool_price - price, + target_price, + pool_price - target_price, ) size = 0 - trades.append((size, (coin_in, coin_out), price)) + trades.append(ArbTrade(coin_in, coin_out, size, target_price)) return trades + + +def _get_arb_direction(pair, pool, market_price): + i, j = pair + price_error_i = pool.price(i, j) - market_price + price_error_j = pool.price(j, i) - 1 / market_price + + if price_error_i >= price_error_j: + target_price = market_price + coin_in, coin_out = i, j + else: + target_price = 1 / market_price + coin_in, coin_out = j, i + + return coin_in, coin_out, target_price diff --git a/curvesim/pipelines/simple/trader.py b/curvesim/pipelines/simple/trader.py index 34769580c..710402fcf 100644 --- a/curvesim/pipelines/simple/trader.py +++ b/curvesim/pipelines/simple/trader.py @@ -41,22 +41,23 @@ def compute_trades(self, prices): best_trade = None price_error = None for t in trades: - size, coins, price_target = t - i, j = coins - min_trade_size = pool.get_min_trade_size(i) - if size <= min_trade_size: + coin_in, coin_out, amount_in, price_target = t + min_trade_size = pool.get_min_trade_size(coin_in) + if amount_in <= min_trade_size: continue with pool.use_snapshot_context(): - out_amount, _ = pool.trade(i, j, size) + amount_out, _ = pool.trade(coin_in, coin_out, amount_in) # assume we transacted at "infinite" depth at target price # on the other exchange to obtain our in-token - profit = out_amount - size * price_target + profit = amount_out - amount_in * price_target if profit > max_profit: max_profit = profit - best_trade = Trade(i, j, size) - price_error = pool.price(i, j) - price_target + best_trade = Trade(coin_in, coin_out, amount_in) + price_error = ( + pool.price(coin_in, coin_out) - price_target + ) / price_target if not best_trade: - return [], {"price_errors": []} + return [], {"price_errors": {}} - return [best_trade], {"price_errors": [price_error]} + return [best_trade], {"price_errors": {(coin_in, coin_out): price_error}} diff --git a/curvesim/pipelines/vol_limited_arb/strategy.py b/curvesim/pipelines/vol_limited_arb/strategy.py index 697c3d23b..51acb5eba 100644 --- a/curvesim/pipelines/vol_limited_arb/strategy.py +++ b/curvesim/pipelines/vol_limited_arb/strategy.py @@ -41,4 +41,6 @@ def _compute_volume_limits(sample, vol_mult): limits = {key: volumes[key] * vol_mult[key] for key in volumes} reversed_limits = {(j, i): lim * prices[(i, j)] for (i, j), lim in limits.items()} - return {**limits, **reversed_limits} + all_limits = {**limits, **reversed_limits} + + return {key: int(val * 10**18) for key, val in all_limits.items()} diff --git a/curvesim/pipelines/vol_limited_arb/trader.py b/curvesim/pipelines/vol_limited_arb/trader.py index c2a575537..46be9c097 100644 --- a/curvesim/pipelines/vol_limited_arb/trader.py +++ b/curvesim/pipelines/vol_limited_arb/trader.py @@ -1,3 +1,5 @@ +from pprint import pformat + from numpy import isnan from scipy.optimize import least_squares @@ -71,37 +73,32 @@ def multipair_optimal_arbitrage( # noqa: C901 pylint: disable=too-many-locals res : scipy.optimize.OptimizeResult Results object from the numerical optimizer. """ - init_trades = get_arb_trades(pool, prices) + all_trades = get_arb_trades(pool, prices) + input_trades, skipped_trades = _apply_volume_limits(all_trades, limits, pool) - # Limit trade size, add size bounds - limited_init_trades = [] - for t in init_trades: - size, pair, price_target = t - limit = int(limits[pair] * 10**18) - t = min(size, limit), pair, price_target, 0, limit + 1 - limited_init_trades.append(t) + if not input_trades: + price_errors = _make_price_errors(skipped_trades=skipped_trades, pool=pool) + return [], price_errors, None - # Order trades in terms of expected size - limited_init_trades = sorted(limited_init_trades, reverse=True, key=lambda t: t[0]) - sizes, coins, price_targets, lo, hi = zip(*limited_init_trades) + input_trades = _sort_trades_by_size(input_trades) + least_squares_inputs = _make_least_squares_inputs(input_trades, limits) - def post_trade_price_error_multi(dxs, price_targets, coins): + def post_trade_price_error_multi(amounts_in, price_targets, coin_pairs): with pool.use_snapshot_context(): - for k, pair in enumerate(coins): - if isnan(dxs[k]): + for coin_pair, amount_in in zip(coin_pairs, amounts_in): + if isnan(amount_in): dx = 0 else: - dx = int(dxs[k]) + dx = int(amount_in) - coin_in, coin_out = pair - min_size = pool.get_min_trade_size(coin_in) + min_size = pool.get_min_trade_size(coin_pair[0]) if dx > min_size: - pool.trade(coin_in, coin_out, dx) + pool.trade(*coin_pair, dx) errors = [] - for k, pair in enumerate(coins): - price = pool.price(*pair, use_fee=True) - errors.append(price - price_targets[k]) + for coin_pair, price_target in zip(coin_pairs, price_targets): + price = pool.price(*coin_pair, use_fee=True) + errors.append(price - price_target) return errors @@ -111,38 +108,109 @@ def post_trade_price_error_multi(dxs, price_targets, coins): try: res = least_squares( post_trade_price_error_multi, - x0=sizes, - args=(price_targets, coins), - bounds=(lo, hi), + **least_squares_inputs, gtol=10**-15, xtol=10**-15, ) - # Format trades into tuples, ignore if dx=0 - dxs = res.x - - for k, amount_in in enumerate(dxs): + # Record optimized trades + for trade, amount_in in zip(input_trades, res.x): if isnan(amount_in): continue amount_in = int(amount_in) - coin_in, coin_out = coins[k] - min_size = pool.get_min_trade_size(coin_in) + min_size = pool.get_min_trade_size(trade.coin_in) if amount_in > min_size: - trades.append(Trade(coin_in, coin_out, amount_in)) + trades.append(Trade(trade.coin_in, trade.coin_out, amount_in)) - errors = res.fun + price_errors = _make_price_errors(input_trades, res.fun, skipped_trades, pool) except Exception: - logger.error( - "Optarbs args: x0: %s, lo: %s, hi: %s, prices: %s", - sizes, - lo, - hi, - price_targets, - exc_info=True, - ) - errors = post_trade_price_error_multi([0] * len(sizes), price_targets, coins) - res = [] + logger.error("Opt Arbs:\n %s", pformat(least_squares_inputs), exc_info=True) + price_errors = _make_price_errors(skipped_trades=all_trades, pool=pool) + res = None + + return trades, price_errors, res + + +def _apply_volume_limits(arb_trades, limits, pool): + """ + Returns list of ArbTrades with amount_in set to min(limit, amount_in). Any trades + limited to less than the pool's minimum trade size are excluded. + """ + + limited_arb_trades = [] + excluded_trades = [] + for trade in arb_trades: + pair = trade.coin_in, trade.coin_out + limited_amount_in = min(limits[pair], trade.amount_in) + lim_trade = trade.replace_amount_in(limited_amount_in) + + if lim_trade.amount_in > pool.get_min_trade_size(lim_trade.coin_in): + limited_arb_trades.append(lim_trade) + else: + excluded_trades.append(lim_trade) + + return limited_arb_trades, excluded_trades + + +def _sort_trades_by_size(trades): + """Sorts trades by amount_in.""" + sorted_trades = sorted(trades, reverse=True, key=lambda t: t.amount_in) + return sorted_trades + + +def _make_least_squares_inputs(trades, limits): + """ + Returns a dict of trades, bounds, and targets formatted as kwargs for least_squares. + """ - return trades, errors, res + coins_in, coins_out, amounts_in, price_targets = zip(*trades) + coin_pairs = tuple(zip(coins_in, coins_out)) + + low_bound = [0] * len(trades) + high_bound = [limits[pair] + 1 for pair in coin_pairs] + + return { + "x0": amounts_in, + "kwargs": {"price_targets": price_targets, "coin_pairs": coin_pairs}, + "bounds": (low_bound, high_bound), + } + + +def _make_price_errors(trades=None, trade_errors=None, skipped_trades=None, pool=None): + """ + Returns a dict mapping coin pairs to price errors. + + Parameters + ---------- + trades : + Trades input into the least_squares optimizer + + trade_errors : + Price errors returned by the optimizer + + skipped_trades : + Trades excluded from optimization + + pool : + The pool used to compute the pool price for skipped trades + + Returns + ------- + price_errors: Dict + Maps coin pairs (tuple) to price_errors + """ + price_errors = {} + if trades: + for trade, price_error in zip(trades, trade_errors): + coin_pair = trade.coin_in, trade.coin_out + price_errors[coin_pair] = price_error / trade.price_target + + if skipped_trades: + for trade in skipped_trades: + coin_pair = trade.coin_in, trade.coin_out + price_error = pool.price(*coin_pair, use_fee=True) - trade.price_target + price_errors[coin_pair] = price_error / trade.price_target + + return price_errors diff --git a/curvesim/templates/trader.py b/curvesim/templates/trader.py index 4be93da32..f6ee16f40 100644 --- a/curvesim/templates/trader.py +++ b/curvesim/templates/trader.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from dataclasses import fields from typing import Union from curvesim.logging import get_logger @@ -16,8 +17,19 @@ class Trade: amount_in: int def __iter__(self): - # pylint: disable=no-member - return (getattr(self, attr) for attr in self.__slots__) + return (getattr(self, field.name) for field in fields(self)) + + +@dataclass(frozen=True, slots=True) +class ArbTrade(Trade): + """Trade object specifying an arbitrage trade.""" + + price_target: float + + def replace_amount_in(self, new_amount_in): + """Returns self, replacing amount_in.""" + coin_in, coin_out, _, price_target = self + return ArbTrade(coin_in, coin_out, new_amount_in, price_target) @dataclass(slots=True) @@ -31,8 +43,7 @@ class TradeResult: fee: int def __iter__(self): - # pylint: disable=no-member - return (getattr(self, attr) for attr in self.__slots__) + return (getattr(self, field.name) for field in fields(self)) def set_attrs(self, **kwargs): """Sets multiple attributes defined by keyword arguments.""" diff --git a/test/unit/test_vol_limited_arb_trader.py b/test/unit/test_vol_limited_arb_trader.py new file mode 100644 index 000000000..8e92acc96 --- /dev/null +++ b/test/unit/test_vol_limited_arb_trader.py @@ -0,0 +1,41 @@ +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from curvesim.pipelines.vol_limited_arb.trader import _apply_volume_limits +from curvesim.templates.trader import ArbTrade + + +class DummyPool: + def __init__(self, min_trade_sizes): + self.min_trade_sizes = min_trade_sizes + + def get_min_trade_size(self, coin_in): + return self.min_trade_sizes[coin_in] + + +random_int = st.integers(min_value=1, max_value=100) +three_ints = st.tuples(random_int, random_int, random_int) + + +@given(three_ints, three_ints, three_ints) +@settings( + max_examples=10, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +def test_apply_volume_limits(trade_sizes, min_trade_sizes, volume_limits): + coins = ["SYM0", "SYM1", "SYM2"] + pairs = [("SYM0", "SYM1"), ("SYM0", "SYM2"), ("SYM1", "SYM2")] + + arb_trades = [ArbTrade(*pair, size, 0) for pair, size in zip(pairs, trade_sizes)] + limits = dict(zip(pairs, volume_limits)) + pool = DummyPool(dict(zip(coins, min_trade_sizes))) + + limited_arb_trades, excluded_trades = _apply_volume_limits(arb_trades, limits, pool) + + for trade in limited_arb_trades: + assert trade.amount_in <= limits[trade.coin_in, trade.coin_out] + assert trade.amount_in > pool.get_min_trade_size(trade.coin_in) + + for trade in excluded_trades: + assert trade.amount_in <= pool.get_min_trade_size(trade.coin_in)