Part II of the Conditional Actions series covered how to build a simple price watcher for Uniswap PEPE-WETH pools.
Now let’s build on that approach to implement a simple limit order.
The PEPE watcher was largely informational. It had some minimal state tracking that allowed it to record the previous price, but only to skip printing update messages if it had not changed.
A limit order also cares about price and some target, but it carries additional complication. For one, the relationship of the price and the target. What if the price is below the target? What if it is above? What if they are equal? These conditions need to be carefully defined.
Token Price Conditionals
I have built some conditionals in the degenbot repo that concern only the instantaneous price for a given token in a Uniswap pool. By keeping the focus of each conditional narrow, they can be easily reused and extended.
Here is the base class:
class BaseCondition(ABC):
# Derived classes must implement a `__call__` method so the
# condition can be evaluated as a callable.
@abstractmethod
def __call__(self) -> bool: ...
And here is a derived class that inherits from it:
class TokenPriceCondition(BaseCondition):
def __init__(
self,
token: Erc20Token,
pool: LiquidityPool | V3LiquidityPool,
target: int | float | Decimal | Fraction,
):
"""
An abstract condition that can access the instantaneous price of
`token` in terms of the other token held by `pool`. The price is
absolute, i.e. it reflects the full decimal precision for the
ERC-20 token contract.
Derived classes should override the `__call__` method to
implement boolean conditions related to this price.
"""
self.token = token
self.pool = pool
self.target = target
@property
def price(self) -> Fraction:
return self.pool.get_absolute_price(self.token)
def update_target(
self,
price: int | float | Decimal | Fraction,
) -> None:
self.target = price
First, a few notes on the structure:
BaseCondition
is an abstract base class that solidifies the interface for any that is common to a conditional.Any classes derived from
BaseCondition
must implement a__callable__
method that returns a boolean. This allows the condition to be checked directly using the syntaxif condition(): …
without needing an arbitrary method.TokenPriceCondition
is also an abstract base class, which inherits fromBaseCondition
. It does not override the abstract method__call__
, and thus cannot be used to instantiate a real object. It’s used as an abstract interface that defines how conditionals related to token prices in Uniswap pools are structured.
That all seems like a lot of effort for no purpose — why build a base class that can’t be used? It begins to make more sense after creating some narrow concrete equality conditionals that implement the __call__
method:
class TokenPriceLessThan(TokenPriceCondition):
def __call__(self) -> bool:
return self.price < self.target
class TokenPriceLessThanOrEqual(TokenPriceCondition):
def __call__(self) -> bool:
return self.price <= self.target
class TokenPriceEquals(TokenPriceCondition):
def __call__(self) -> bool:
return self.price == self.target
class TokenPriceGreaterThan(TokenPriceCondition):
def __call__(self) -> bool:
return self.price > self.target
class TokenPriceGreaterThanOrEqual(TokenPriceCondition):
def __call__(self) -> bool:
return self.price > self.target
If I had not built these five conditionals from the base class, each would require their own __init__
, update_target
, and price
method.
They would all generally look the same, too. Code duplication is bad, especially when duplicated across several related classes. If I need to implement more functionality, the likely place to do it is within TokenPriceCondition
base class. Once that is done, the extended functionality flows downstream to the equality methods without any further work.
Building The Limit Order
I went through the trouble of defining these token price conditionals because a limit order needs to correctly evaluate the current price against the target price.
Sometimes you want to buy when the price drops below a certain target, or above a certain target, or perhaps when they are equal.
To this end, let’s create an enumeration of price modes describing the price <> target comparison that might trigger this limit order:
class PriceModes(enum.Enum):
LESS_THAN = enum.auto()
LESS_THAN_OR_EQUAL = enum.auto()
EQUALS = enum.auto()
GREATER_THAN_OR_EQUAL = enum.auto()
GREATER_THAN = enum.auto()
Now let’s create a more focused UniswapLimitOrder
class. Its constructor accepts more narrowly-focused inputs specific to working with Uniswap pools:
class UniswapPriceLimitOrder(ConditionalAction):
def __init__(
self,
pool: LiquidityPool | V3LiquidityPool,
buy_token: Erc20Token,
comparison: ComparisonModes,
target: int | float | Decimal | Fraction,
actions: Sequence[Callable[[], Any]],
):
"""
A Uniswap pool limit order, conditionally executed against the
*nominal* price of `buy_token` in the given `pool`. The nominal
price is expressed ignoring the decimal multiplier set by the
token contract, e.g. 1 WETH / DAI instead of
1*10**18 WETH / 1*10**18 DAI
"""
self.buy_token = buy_token
self.pool = pool
if buy_token not in pool.tokens:
raise ValueError(f"{buy_token} not found in {pool}")
if isinstance(target, Decimal):
target = Fraction.from_decimal(target)
elif isinstance(target, float):
target = Fraction.from_float(target)
# Price conditionals are evaluated against the absolute price,
# so convert from nominal
if buy_token == pool.token0:
absolute_price_target = target * Fraction(
10**pool.token1.decimals,
10**pool.token0.decimals,
)
else:
absolute_price_target = target * Fraction(
10**pool.token0.decimals,
10**pool.token1.decimals,
)
match comparison:
case ComparisonModes.LESS_THAN:
self.condition = TokenPriceLessThan(
token=buy_token,
pool=pool,
target=absolute_price_target,
)
case ComparisonModes.LESS_THAN_OR_EQUAL:
self.condition = TokenPriceLessThanOrEqual(
token=buy_token,
pool=pool,
target=absolute_price_target,
)
case ComparisonModes.EQUALS:
self.condition = TokenPriceEquals(
token=buy_token,
pool=pool,
target=absolute_price_target,
)
case ComparisonModes.GREATER_THAN_OR_EQUAL:
self.condition = TokenPriceGreaterThanOrEqual(
token=buy_token,
pool=pool,
target=absolute_price_target,
)
case ComparisonModes.GREATER_THAN:
self.condition = TokenPriceGreaterThan(
token=buy_token,
pool=pool,
target=absolute_price_target,
)
case _:
raise ValueError(
f"Unknown price mode {comparison} specified"
)
self.actions = actions
The token price conditionals compare with full precision, so the user-facing class (which accepts nominal values), must convert that before creating the conditional.
Price vs. Rate
But first, a diversion to discuss some important terms.
The price recorded by a Uniswap V3 pool is a rate of exchange from token0 to token1. For simplicity, we preserve the convention when working with Uniswap V2.
As I write this, the USD price for Ether is $3750. I will use WETH and DAI to represent these two assets because they have equal decimal values, thus the complications of nominal vs. absolute values do not apply.
Take a hypothetical WETH-DAI pool with 1 WETH and 3,750 DAI.
By the definitions in Part II, the rate of exchange for WETH is:
We tend to think of price as the rate of exchange for a unit output, instead of a unit input. That is, instead of the amount of WETH received for 1 DAI, we consider how many DAI are required to purchase 1 WETH.
To convert, simply invert the rate and arrive at the expected price:
For a more thorough treatment, read the V3 Math Primer published by Uniswap. They reference the conversion from rate to price as taking the “multiplicative inverse”.
What Does This Mean For My Limit Order?
When we set up a limit order, we must take care to format it correctly. We can express the target as a price or a rate. One implies the other, but the language should match because translating between them in your head is prone to error.
Here’s an example — say that I want to buy WETH when the price goes below the current price (3,750 DAI). When this occurs, will the rate of exchange (expressed in WETH per DAI) be lower or higher? Higher!
As price drops, rate of exchange increases. As price increases, rate of exchange drops. This inversion is difficult to reason about. We have two choices:
Keep track of this mentally — bad
Let the limit order handle the conversion — good
The cost of this reduced run-time burden is extra development at the front end. We know about prices, and rates, and absolute values, and nominal values. So that’s four ways to input a limit order!
I have consolidated everything into two major classes (UniswapPriceLimitOrder
and UniswapRatioLimitOrder
) which accept nominal price and ratio targets. I have used “ratio” as a shorthand for “rate of exchange”.
I have also created a separate set of conditionals that can evaluate the rate of exchange instead of the price:
class TokenRatioCondition(BaseCondition):
def __init__(
self,
token: Erc20Token,
pool: LiquidityPool | V3LiquidityPool,
target: int | float | Decimal | Fraction,
):
"""
An abstract condition that can access the instantaneous rate of
exchange (ratio) of `token` in terms of the other token held by
`pool`. The price is absolute, i.e. it reflects the full decimal
precision for the ERC-20 token contract.
Derived classes should override the `__call__` method to
implement boolean conditions related to this price.
"""
self.token = token
self.pool = pool
self.target = target
@property
def exchange_rate(self) -> Fraction:
return self.pool.get_absolute_rate(self.token)
def update_target(
self,
price: int | float | Decimal | Fraction,
) -> None:
self.target = price
class TokenRatioLessThan(TokenRatioCondition):
def __call__(self) -> bool:
return self.exchange_rate < self.target
class TokenRatioLessThanOrEqual(TokenRatioCondition):
def __call__(self) -> bool:
return self.exchange_rate <= self.target
class TokenRatioEquals(TokenRatioCondition):
def __call__(self) -> bool:
return self.exchange_rate == self.target
class TokenRatioGreaterThan(TokenRatioCondition):
def __call__(self) -> bool:
return self.exchange_rate > self.target
class TokenRatioGreaterThanOrEqual(TokenRatioCondition):
def __call__(self) -> bool:
return self.exchange_rate > self.target
Now we can express different intentions without needing to translate concepts between different accounting schemes.
Example
Let’s take our PEPE script from last week and extend it to implement some impressive (but fake) limit orders:
pepe_limit_order.py