Skip to content

Commit

Permalink
improve arb bot accuracy (#1331)
Browse files Browse the repository at this point in the history
handle 100x bigger trades within same absolute tolerance
  • Loading branch information
wakamex authored Feb 27, 2024
1 parent 1430ea6 commit 90d2aec
Show file tree
Hide file tree
Showing 4 changed files with 272 additions and 244 deletions.
64 changes: 31 additions & 33 deletions lib/agent0/agent0/hyperdrive/policies/lpandarb.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
open_long_trade,
open_short_trade,
)
from agent0.utilities.predict import predict_long, predict_short

from .hyperdrive_policy import HyperdriveBasePolicy

Expand All @@ -37,8 +38,11 @@


def calc_shares_needed_for_bonds(
bonds_needed: FixedPoint, pool_state: PoolState, interface: HyperdriveReadInterface
) -> tuple[FixedPoint, FixedPoint]:
bonds_needed: FixedPoint,
pool_state: PoolState,
interface: HyperdriveReadInterface,
minimum_trade_amount: FixedPoint,
) -> FixedPoint:
"""Calculate the shares needed to trade a certain amount of bonds, and the associate governance fee.
Arguments
Expand All @@ -49,28 +53,26 @@ def calc_shares_needed_for_bonds(
The hyperdrive pool state.
interface: HyperdriveReadInterface
The Hyperdrive API interface object.
minimum_trade_amount: FixedPoint
The minimum amount of bonds needed to open a trade.
Returns
-------
tuple[FixedPoint, FixedPoint]
_shares_to_pool: FixedPoint
The change in shares in the pool for the given amount of bonds.
_shares_to_gov: FixedPoint
The associated shares going to governance.
FixedPoint
The change in shares in the pool for the given amount of bonds.
"""
_shares_to_pool = interface.calc_shares_out_given_bonds_in_down(abs(bonds_needed), pool_state)
spot_price = interface.calc_spot_price(pool_state)
price_discount = FixedPoint(1) - spot_price
_shares_to_gov = (
_shares_to_pool * price_discount * pool_state.pool_config.fees.curve * pool_state.pool_config.fees.governance_lp
)
_shares_to_pool -= _shares_to_gov
return _shares_to_pool, _shares_to_gov
if bonds_needed > minimum_trade_amount: # need more bonds in pool -> user sells bonds -> user opens short
delta = predict_short(hyperdrive_interface=interface, bonds=bonds_needed, pool_state=pool_state, for_pool=True)
elif bonds_needed < -minimum_trade_amount: # need less bonds in pool -> user buys bonds -> user opens long
delta = predict_long(hyperdrive_interface=interface, bonds=-bonds_needed, pool_state=pool_state, for_pool=True)
else:
return FixedPoint(0)
return abs(delta.pool.shares)


def calc_reserves_to_hit_target_rate(
target_rate: FixedPoint, interface: HyperdriveReadInterface
target_rate: FixedPoint, interface: HyperdriveReadInterface, minimum_trade_amount: FixedPoint
) -> tuple[FixedPoint, FixedPoint, int, float]:
"""Calculate the bonds and shares needed to hit the target fixed rate.
Expand All @@ -80,6 +82,8 @@ def calc_reserves_to_hit_target_rate(
The target rate the pool will have after the calculated change in bonds and shares.
interface: HyperdriveReadInterface
The Hyperdrive API interface object.
minimum_trade_amount: FixedPoint
The minimum amount of bonds needed to open a trade.
Returns
-------
Expand Down Expand Up @@ -121,9 +125,9 @@ def calc_reserves_to_hit_target_rate(
# So we loop through, increasing the divisor until the share reserves are no longer negative.
while avoid_negative_share_reserves is False:
bonds_needed = (target_bonds - pool_state.pool_info.bond_reserves) / divisor
shares_to_pool, shares_to_gov = calc_shares_needed_for_bonds(bonds_needed, pool_state, interface)
shares_to_pool = calc_shares_needed_for_bonds(bonds_needed, pool_state, interface, minimum_trade_amount)
# save bad first guess to a temporary variable
temp_pool_state = apply_step(deepcopy(pool_state), bonds_needed, shares_to_pool, shares_to_gov)
temp_pool_state = apply_step(deepcopy(pool_state), bonds_needed, shares_to_pool)
predicted_rate = interface.calc_fixed_rate(temp_pool_state)
avoid_negative_share_reserves = temp_pool_state.pool_info.share_reserves >= 0
divisor *= FixedPoint(2)
Expand All @@ -133,9 +137,9 @@ def calc_reserves_to_hit_target_rate(
overshoot_or_undershoot = (predicted_rate - latest_fixed_rate) / (target_rate - latest_fixed_rate)
if overshoot_or_undershoot != FixedPoint(0):
bonds_needed = bonds_needed / overshoot_or_undershoot
shares_to_pool, shares_to_gov = calc_shares_needed_for_bonds(bonds_needed, pool_state, interface)
shares_to_pool = calc_shares_needed_for_bonds(bonds_needed, pool_state, interface, minimum_trade_amount)
# update pool state with second guess and continue from there
pool_state = apply_step(pool_state, bonds_needed, shares_to_pool, shares_to_gov)
pool_state = apply_step(pool_state, bonds_needed, shares_to_pool)
predicted_rate = interface.calc_fixed_rate(pool_state)
# update running totals
total_shares_needed = (
Expand All @@ -157,8 +161,7 @@ def calc_reserves_to_hit_target_rate(
def apply_step(
pool_state: PoolState,
bonds_needed: FixedPoint,
shares_needed: FixedPoint,
gov_fee: FixedPoint,
shares_to_pool: FixedPoint,
) -> PoolState:
"""Save a single convergence step into the pool info.
Expand All @@ -168,25 +171,18 @@ def apply_step(
The current pool state.
bonds_needed: FixedPoint
The amount of bonds that is going to be traded.
shares_needed: FixedPoint
shares_to_pool: FixedPoint
The amount of shares that is going to be traded.
gov_fee: FixedPoint
The associated governance fee.
Returns
-------
PoolState
The updated pool state.
"""
if bonds_needed > 0: # short case
# shares_needed is what the user takes OUT: curve_fee less due to fees.
# gov_fee of that doesn't stay in the pool, going OUT to governance (same direction as user flow).
pool_state.pool_info.share_reserves += -shares_needed - gov_fee
pool_state.pool_info.share_reserves += -shares_to_pool # take shares out of pool
else: # long case
# shares_needed is what the user pays IN: curve_fee more due to fees.
# gov_fee of that doesn't go to the pool, going OUT to governance (opposite direction of user flow).
pool_state.pool_info.share_reserves += shares_needed - gov_fee
pool_state.pool_info.share_reserves += shares_to_pool # put shares in pool
pool_state.pool_info.bond_reserves += bonds_needed
return pool_state

Expand Down Expand Up @@ -324,7 +320,9 @@ def action(
bonds_needed = FixedPoint(0)
if high_fixed_rate_detected or low_fixed_rate_detected:
_, bonds_needed, iters, speed = calc_reserves_to_hit_target_rate(
target_rate=interface.current_pool_state.variable_rate, interface=interface
target_rate=interface.current_pool_state.variable_rate,
interface=interface,
minimum_trade_amount=self.minimum_trade_amount,
)
self.convergence_iters.append(iters)
self.convergence_speed.append(speed)
Expand Down
2 changes: 1 addition & 1 deletion lib/agent0/agent0/hyperdrive/policies/lpandarb_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# avoid unnecessary warning from using fixtures defined in outer scope
# pylint: disable=redefined-outer-name

TRADE_AMOUNTS = [0.003, 1e4, 1e5] # 0.003 is three times the minimum transaction amount of local test deploy
TRADE_AMOUNTS = [0.003, 1e7] # 0.003 is three times the minimum transaction amount of local test deploy
# We hit the target rate to the 5th decimal of precision.
# That means 0.050001324091154488 is close enough to a target rate of 0.05.
PRECISION = FixedPoint(1e-5)
Expand Down
231 changes: 231 additions & 0 deletions lib/agent0/agent0/utilities/predict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"""Predict the outcome of trades.
A trade results in changes to 4 entities, measured in 3 units.
accounts: pool, user, fee, governance
units: base, bonds, shares (base and shares refer to the same account)
Applications where this is useful:
LP and Arb bot uses this logic to hit a target rate.
Trade by specifying the output units (base for open long, bonds otherwise).
For example:
+------------+--------------+---------------+--------------+
| Entity | Base | Bonds | Shares |
+============+==============+===============+==============+
| user | 100 | 104.95 | 100 |
+------------+--------------+---------------+--------------+
| pool | 99.9952 | -104.955 | 99.9952 |
+------------+--------------+---------------+--------------+
| fee | 0.0428367 | 0.0449786 | 0.0428367 |
+------------+--------------+---------------+--------------+
| governance | 0.00475964 | 0.00499762 | 0.00475964 |
+------------+--------------+---------------+--------------+
"""

from __future__ import annotations

from copy import deepcopy
from typing import NamedTuple

from ethpy.hyperdrive.interface.read_interface import HyperdriveReadInterface
from ethpy.hyperdrive.state import PoolState
from fixedpointmath import FixedPoint

# need to cover a wide variety of cases
# pylint: disable=too-many-arguments
# being very explicit
# pylint: disable=too-many-locals

YEAR_IN_SECONDS = 31_536_000
YEAR_IN_BLOCKS = YEAR_IN_SECONDS / 12

Deltas = NamedTuple(
"Deltas",
[
("base", FixedPoint),
("bonds", FixedPoint),
("shares", FixedPoint),
],
)
TradeDeltas = NamedTuple(
"TradeDeltas",
[
("user", Deltas),
("pool", Deltas),
("fee", Deltas),
("governance", Deltas),
],
)


def _get_vars(hyperdrive_interface, pool_state):
if pool_state is None:
pool_state = deepcopy(hyperdrive_interface.current_pool_state)
spot_price = hyperdrive_interface.calc_spot_price(pool_state)
price_discount = FixedPoint(1) - spot_price
curve_fee = pool_state.pool_config.fees.curve
governance_fee = pool_state.pool_config.fees.governance_lp
share_price = hyperdrive_interface.current_pool_state.pool_info.vault_share_price
return pool_state, spot_price, price_discount, curve_fee, governance_fee, share_price


def predict_long(
hyperdrive_interface: HyperdriveReadInterface,
pool_state: PoolState | None = None,
base: FixedPoint | None = None,
bonds: FixedPoint | None = None,
for_pool: bool = False,
) -> TradeDeltas:
"""Predict the outcome of a long trade.
Arguments
---------
hyperdrive_interface: HyperdriveReadInterface
Hyperdrive interface.
pool_state: PoolState, optional
The state of the pool, which includes block details, pool config, and pool info.
If not given, use the current pool state.
base: FixedPoint, optional
The size of the long to open, in base. If not given, converted from bonds.
bonds: FixedPoint, optional
The size of the long to open, in bonds.
for_pool: bool
Whether the base or bonds specified is for the pool.
Returns
-------
TradeDeltas
The predicted deltas of base, bonds, and shares.
"""
pool_state, spot_price, price_discount, curve_fee, governance_fee, share_price = _get_vars(
hyperdrive_interface, pool_state
)
if base is not None and bonds is None:
if for_pool is False:
base_needed = base
else:
# scale up input to account for fees
base_needed = base / (FixedPoint(1) - price_discount * governance_fee)
elif bonds is not None and base is None:
# we need to calculate base_needed
bonds_needed = bonds
shares_needed = hyperdrive_interface.calc_shares_in_given_bonds_out_up(bonds_needed)
# scale down output to account for fees
if for_pool is False:
shares_needed /= FixedPoint(1) - price_discount * curve_fee
else:
shares_needed /= FixedPoint(1) - price_discount * curve_fee * governance_fee
share_price_on_next_block = share_price * (
FixedPoint(1) + hyperdrive_interface.get_variable_rate(pool_state.block_number) / FixedPoint(YEAR_IN_BLOCKS)
)
base_needed = shares_needed * share_price_on_next_block
else:
raise ValueError("Need to specify either bonds or base, but not both.")
# continue with common logic, now that we have base_needed
assert base_needed is not None
bonds_after_fees = hyperdrive_interface.calc_open_long(base_needed)
bond_fees = bonds_after_fees * price_discount * curve_fee
bond_fees_to_pool = bond_fees * (FixedPoint(1) - governance_fee)
bond_fees_to_gov = bond_fees * governance_fee
predicted_delta_bonds = -bonds_after_fees - bond_fees_to_gov
# gov_scaling factor is the ratio by which we lower the change in base and increase the change in shares
# this is done to take into account the effect of the governance fee on pool reserves
gov_scaling_factor = FixedPoint(1) - price_discount * curve_fee * governance_fee
predicted_delta_base = base_needed * gov_scaling_factor
predicted_delta_shares = base_needed / share_price * gov_scaling_factor
return TradeDeltas(
user=Deltas(bonds=bonds_after_fees, base=base_needed, shares=base_needed / share_price),
pool=Deltas(
base=predicted_delta_base,
shares=predicted_delta_shares,
bonds=predicted_delta_bonds,
),
fee=Deltas(
bonds=bond_fees_to_pool,
base=bond_fees_to_pool * spot_price,
shares=bond_fees_to_pool * spot_price * share_price,
),
governance=Deltas(
bonds=bond_fees_to_gov,
base=bond_fees_to_gov * spot_price,
shares=bond_fees_to_gov * spot_price * share_price,
),
)


def predict_short(
hyperdrive_interface: HyperdriveReadInterface,
pool_state: PoolState | None = None,
base: FixedPoint | None = None,
bonds: FixedPoint | None = None,
for_pool: bool = False,
) -> TradeDeltas:
"""Predict the outcome of a short trade.
Arguments
---------
hyperdrive_interface: HyperdriveReadInterface
Hyperdrive interface.
pool_state: PoolState, optional
The state of the pool, which includes block details, pool config, and pool info.
If not given, use the current pool state.
base: FixedPoint, optional
The size of the short to open, in base.
bonds: FixedPoint, optional
The size of the short to open, in bonds. If not given, bonds is calculated from base.
for_pool: bool
Whether the base or bonds specified is for the pool.
Returns
-------
TradeDeltas
The predicted deltas of base, bonds, and shares.
"""
pool_state, spot_price, price_discount, curve_fee, governance_fee, share_price = _get_vars(
hyperdrive_interface, pool_state
)
if bonds is not None and base is None:
if for_pool is False:
bonds_needed = bonds
else:
# scale up input to account for fees
bonds_needed = bonds * (FixedPoint(1) - price_discount * curve_fee * governance_fee)
elif base is not None and bonds is None:
# we need to calculate bonds_needed
base_needed = base
# this is the wrong direction for the swap, but we don't have the function in the other direction
bonds_needed = hyperdrive_interface.calc_bonds_out_given_shares_in_down(base_needed / share_price)
# scale down output to account for fees
if for_pool is False:
bonds_needed /= FixedPoint(1) - price_discount * curve_fee * (FixedPoint(1) - governance_fee)
else:
bonds_needed /= FixedPoint(1) - price_discount * curve_fee
else:
raise ValueError("Need to specify either bonds or base, but not both.")
shares_before_fees = hyperdrive_interface.calc_shares_out_given_bonds_in_down(bonds_needed)
base_fees = bonds_needed * price_discount * curve_fee
base_fees_to_pool = base_fees * (FixedPoint(1) - governance_fee)
base_fees_to_gov = base_fees * governance_fee
shares_after_fees = shares_before_fees + base_fees_to_pool + base_fees_to_gov
base_after_fees = shares_after_fees * share_price
predicted_delta_bonds = bonds_needed
predicted_delta_shares = -shares_before_fees + base_fees_to_pool
predicted_delta_base = predicted_delta_shares * share_price
return TradeDeltas(
user=Deltas(bonds=bonds_needed, base=base_after_fees, shares=shares_after_fees),
pool=Deltas(
base=predicted_delta_base,
shares=predicted_delta_shares,
bonds=predicted_delta_bonds,
),
fee=Deltas(
bonds=base_fees_to_pool / spot_price,
base=base_fees_to_pool,
shares=base_fees_to_pool / share_price,
),
governance=Deltas(
bonds=base_fees_to_gov / spot_price,
base=base_fees_to_gov,
shares=base_fees_to_gov / share_price,
),
)
Loading

0 comments on commit 90d2aec

Please sign in to comment.