Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Limit order support in driver #713

Merged
merged 16 commits into from
Nov 9, 2022
Merged

Conversation

MartinquaXD
Copy link
Contributor

@MartinquaXD MartinquaXD commented Nov 3, 2022

Fixes #705

To not require any special handling on the solver side to settle limit orders we have to manage them a bit under the hood:

  1. When creating the LimitOrder type in the driver we deduct the surplus_fee from the sell_amount in the OrderConverter
  2. The solver computes a solution as it would for any other OrderClass::Ordinary order
  3. Compute an adjusted buy_price which takes the original sell_amount into account (SettlementEncoder)

Test Plan

Added unit tests for OrderConverter and price adjustment logic in SettlementEncoder

@MartinquaXD MartinquaXD requested a review from a team as a code owner November 3, 2022 14:01
Copy link
Contributor Author

@MartinquaXD MartinquaXD left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is not done yet but because prices are tricky I wanted to get a few eyes on the implementation to avoid spending time on writing tests for incorrect math.
Also I'm not sure where (if at all) I have to take buy vs sell orders into account.

crates/solver/src/settlement/settlement_encoder.rs Outdated Show resolved Hide resolved
/// `compute_synthetic_order_amounts_if_limit_order()`).
/// Returns an error if the UCP doesn't contain the `sell_token` or if under- or overflows
/// happen during the computation.
fn custom_price_for_limit_order(&self, order: &Order) -> Result<U256> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed that this function name might get confusing together with compute_limit_buy_price() (used for liquidity orders). I'm open for naming suggestions to make the distinction clearer.

// But actually this is the equation the solution has to satisfy:
// uniform_sell_price * sell_amount = custom_buy_price * buy_amount
// That means we can solve for the custom_buy_price like this:
// custom_buy_price = (uniform_sell_price * sell_amount) / buy_amount
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something feels off here since the the buy price is independent of the uniform_buy_price...

Copy link
Contributor Author

@MartinquaXD MartinquaXD Nov 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this make more sense?

  1. Use UCP buy and sell price to compute the actual buy_amount adjusted for the fee_surplus (it should be lower)
  2. Use the existing formula but with the new reduced_buy_amount
    custom_buy_price = (uniform_sell_price * sell_amount) / reduced_buy_amount

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and I think you also need to account for order side (sell/buy). I.e., compute the buy_amount for sell orders, and sell_amount for buy orders.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harisang... do you mind double-checking our logic here 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how I understand the problem after the last discussion:

    fn custom_price_for_limit_order(&self, order: &Order) -> Result<U256> {
        let uniform_buy_price = self
            .clearing_prices
            .get(&order.data.buy_token)
            .context("buy token price is missing")?;
        let uniform_sell_price = self
            .clearing_prices
            .get(&order.data.sell_token)
            .context("sell token price is missing")?;

        let (sell_amount, buy_amount) = match order.data.kind {
            // This means sell as much `sell_token` as needed to buy exactly the expected
            // `buy_amount`. Therefore we need to solve for `sell_amount`.
            OrderKind::Buy => {
                let sell_amount = order.data.buy_amount * uniform_buy_price / uniform_sell_price;
                // We have to sell slightly more `sell_token` to capture the `surplus_fee`
                let sell_amount_adjusted_for_fees = sell_amount + order.metadata.surplus_fee;
                (sell_amount_adjusted_for_fees, order.data.buy_amount)
            }
            // This means sell ALL the `sell_token` and get as much `buy_token` as possible.
            // Therefore we need to solve for `buy_amount`.
            OrderKind::Sell => {
                // Solver actually used this `sell_amount` to compute prices.
                let sell_amount = order.data.sell_amount - order.metadata.surplus_fee;
                let buy_amount = sell_amount * uniform_sell_price / uniform_buy_price;
                (order.data.sell_amount, buy_amount)
            }
        };

        let custom_buy_price = sell_amount * uniform_sell_price / buy_amount;
        Ok(custom_buy_price)
    }

I didn't use checked math operations to keep it succinct for this comment.
Also in the second to last line I still have to add order.data.fee_amount in case we want to use a non 0 fee at some point in the future, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks correct to me now.

But... its price related so 🤷.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did some napkin math and the equations look correct to me ✅.

@MartinquaXD MartinquaXD force-pushed the refactor-adding-liquidity-trades-to-encoder branch from dbadb44 to 836b7f7 Compare November 4, 2022 10:57
Base automatically changed from refactor-adding-liquidity-trades-to-encoder to main November 4, 2022 11:04
@harisang
Copy link
Contributor

harisang commented Nov 4, 2022

Sorry, just got to see this. Will try to catch up and review this. Also, is terminology defined anywhere (e.g., some issue in the repo, or notion etc?)

@MartinquaXD
Copy link
Contributor Author

I tried to implement what is described in this notion page. But I didn't consciously try to adhere to the terminology used there so it might not be that helpful.

Copy link
Contributor

@nlordell nlordell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

I'm not super confident about custom_price_for_limit_order (as it confuses me as well), but the rest looks good! Maybe a unit test that verifies that the additional prices for limit orders is correct would be good?

Comment on lines 85 to 89
.checked_add(order.data.fee_amount)
.context("surplus_fee adjustment would overflow sell_amount")?
.checked_sub(order.metadata.surplus_fee)
.context("surplus_fee adjustment would underflow sell_amount")?;
return Ok((sell_amount, order.metadata.surplus_fee));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sense that surplus_fee is subtracted from sell_amount. But why is fee_amount added? Maybe was explained in the discussion on Github but when I read this code here it is unclear.
Naively I'd think that surplus_fee already takes into account that the order was a real fee too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naively I'd think that surplus_fee already takes into account that the order was a real fee too.

Then it would need to be a signed amount to account for setting a too high fee_amount.

Copy link
Contributor

@nlordell nlordell Nov 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically, surplus_fee is the exact fee amount that needs to be taken for the order for that auction (regardless of what the actually set fee_amount is). This leads to the following system of equations:

(1)
synthetic_order.fee_amount = surplus_fee

(2)
synthetic_order.sell_amount + synthetic_order.fee_amount
  = order.sell_amount + order.fee_amount
  
// Solving for synthetic_order.sell_amount:
(3)
synthetic_order.sell_amount
  = order.sell_amount + order.fee_amount - surplus_fee

For limit orders, you can imagine they are an order without pro-rated fee. Because the settlement contract always transfers in order.sell_amount + order.fee_amount, we need to account for non-0 fee amounts here.

/// `compute_synthetic_order_amounts_if_limit_order()`).
/// Returns an error if the UCP doesn't contain the traded tokens or if under- or overflows
/// happen during the computation.
fn custom_price_for_limit_order(&self, order: &Order) -> Result<U256> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe to make this easier to test we can pass in the uniform prices you get from self so that we have a freestanding function. Then the standalone tests also makes the behavior easier to understand and can be combined with compute_synthetic_order_amounts_if_limit_order.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vkgnosis This is the exact thing I'm trying to argue against 😄

Copy link
Contributor

@sistemd sistemd Nov 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a saying that "testable code == clean code" and this is true in some cases and false in others. This is one case where it's false. I see this elsewhere in the codebase as well where functions feel like they should be methods, and they accept a bunch of fields from self, but they were written as functions just so they could be tested.

This is testing internal APIs (! I keep saying that), which makes them harder to change. Not only that, but the code is also more awkward because the reader wonders "huh, why isn't this a method...?"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recognize it's something we disagree on.

@MartinquaXD
Copy link
Contributor Author

@harisang I added a few unit tests. Could you please check if they make sense to you.

},
encoder.custom_price_trades[0]
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Copy link
Contributor

@vkgnosis vkgnosis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks good but I did not think about the math in detail.

Copy link
Contributor

@sistemd sistemd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some nits/code quality comments. The code does make sense to me with my limited domain knowledge.

@@ -71,14 +74,32 @@ struct OrderSettlementHandler {
scaled_unsubsidized_fee_amount: U256,
}

/// Returns (`sell_amount`, `fee_amount`) for the given order and adjusts the values accordingly
/// for limit orders.
fn compute_synthetic_order_amounts_if_limit_order(order: &Order) -> Result<(U256, U256)> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, though I'm still not a fan of this name

Suggested change
fn compute_synthetic_order_amounts_if_limit_order(order: &Order) -> Result<(U256, U256)> {
fn compute_synthetic_order_amounts_for_limit_order(order: &Order) -> Result<(U256, U256)> {

The confusing part is that "limit order" now might refer to either LimitOrder or Order with OrderClass::Limit. 😕

Copy link
Contributor

@sistemd sistemd Nov 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better if the if could be hoisted out of the function. Generally if you have a function named do_a_if_b it's better to split into two functions, do_a and b:

if b() {
  do_a();
}

Comment on lines 82 to 88
let sell_amount = order
.data
.sell_amount
.checked_add(order.data.fee_amount)
.context("surplus_fee adjustment would overflow sell_amount")?
.checked_sub(order.metadata.surplus_fee)
.context("surplus_fee adjustment would underflow sell_amount")?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How common will it be for surplus_fee to be higher than sell_amount? I imagine it could happen but I'm not sure if such an order even makes sense. The way this code is written now, such an order would result in an error here, but I'm not sure how that error is handled. Is it logged as an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be surprised if that happened often. If it does we will get an alert and the order will be omitted from the auction.

@@ -195,13 +195,89 @@ impl SettlementEncoder {
)?
}
OrderClass::Limit => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch doesn't use the executed_amount parameter. That's pointing to a mistake in how this code is organized. Not necessarily on you, but this needs to be refactored.

Copy link
Contributor Author

@MartinquaXD MartinquaXD Nov 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from the fact that the executed_amount is kind of meaningless for this order type because solvers can't know the actual executed amount. After all we gave them a synthetic order with adjusted sell_amount.
We could assert that the solver at least used the fake sell_amount we gave them but that wouldn't help much.

@harisang
Copy link
Contributor

harisang commented Nov 9, 2022

@harisang I added a few unit tests. Could you please check if they make sense to you.

The two tests look good to me!

@MartinquaXD MartinquaXD enabled auto-merge (squash) November 9, 2022 12:58
@MartinquaXD MartinquaXD merged commit 25a38a8 into main Nov 9, 2022
@MartinquaXD MartinquaXD deleted the driver-support-limit-orders branch November 9, 2022 13:02
@github-actions github-actions bot locked and limited conversation to collaborators Nov 9, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Compute synthetic order in the driver
5 participants