diff options
-rw-r--r-- | packages/asset-buyer/src/utils/buy_quote_calculator.ts | 147 | ||||
-rw-r--r-- | packages/asset-buyer/test/buy_quote_calculator_test.ts | 130 |
2 files changed, 246 insertions, 31 deletions
diff --git a/packages/asset-buyer/src/utils/buy_quote_calculator.ts b/packages/asset-buyer/src/utils/buy_quote_calculator.ts index b706ea143..53f2228e9 100644 --- a/packages/asset-buyer/src/utils/buy_quote_calculator.ts +++ b/packages/asset-buyer/src/utils/buy_quote_calculator.ts @@ -1,4 +1,4 @@ -import { marketUtils } from '@0xproject/order-utils'; +import { marketUtils, rateUtils } from '@0xproject/order-utils'; import { BigNumber } from '@0xproject/utils'; import * as _ from 'lodash'; @@ -21,6 +21,7 @@ export const buyQuoteCalculator = { const feeOrders = feeOrdersAndFillableAmounts.orders; const remainingFillableFeeAmounts = feeOrdersAndFillableAmounts.remainingFillableMakerAssetAmounts; const slippageBufferAmount = assetBuyAmount.mul(slippagePercentage).round(); + // find the orders that cover the desired assetBuyAmount (with slippage) const { resultOrders, remainingFillAmount, @@ -29,9 +30,11 @@ export const buyQuoteCalculator = { remainingFillableMakerAssetAmounts, slippageBufferAmount, }); + // if we do not have enough orders to cover the desired assetBuyAmount, throw if (remainingFillAmount.gt(constants.ZERO_AMOUNT)) { throw new Error(AssetBuyerError.InsufficientAssetLiquidity); } + // given the orders calculated above, find the fee-orders that cover the desired assetBuyAmount (with slippage) // TODO(bmillman): optimization // update this logic to find the minimum amount of feeOrders to cover the worst case as opposed to // finding order that cover all fees, this will help with estimating ETH and minimizing gas usage @@ -40,49 +43,131 @@ export const buyQuoteCalculator = { remainingFeeAmount, feeOrdersRemainingFillableMakerAssetAmounts, } = marketUtils.findFeeOrdersThatCoverFeesForTargetOrders(resultOrders, feeOrders, { - remainingFillableMakerAssetAmounts, + remainingFillableMakerAssetAmounts: ordersRemainingFillableMakerAssetAmounts, remainingFillableFeeAmounts, }); + // if we do not have enough feeOrders to cover the fees, throw if (remainingFeeAmount.gt(constants.ZERO_AMOUNT)) { throw new Error(AssetBuyerError.InsufficientZrxLiquidity); } + // assetData information for the result const assetData = orders[0].makerAssetData; - - // calculate minRate and maxRate by calculating min and max eth usage and then dividing into - // assetBuyAmount to get assetData / WETH, needs to take into account feePercentage as well - // minEthAmount = (sum(takerAssetAmount[i]) until sum(makerAssetAmount[i]) >= assetBuyAmount ) * (1 + feePercentage) - // maxEthAmount = (sum(takerAssetAmount[i]) until i == orders.length) * (1 + feePercentage) - const allOrders = _.concat(resultOrders, resultFeeOrders); - const allRemainingAmounts = _.concat( - ordersRemainingFillableMakerAssetAmounts, - feeOrdersRemainingFillableMakerAssetAmounts, + // compile the resulting trimmed set of orders for makerAsset and feeOrders that are needed for assetBuyAmount + const trimmedOrdersAndFillableAmounts: OrdersAndFillableAmounts = { + orders: resultOrders, + remainingFillableMakerAssetAmounts: ordersRemainingFillableMakerAssetAmounts, + }; + const trimmedFeeOrdersAndFillableAmounts: OrdersAndFillableAmounts = { + orders: resultFeeOrders, + remainingFillableMakerAssetAmounts: feeOrdersRemainingFillableMakerAssetAmounts, + }; + const minRate = calculateRate( + trimmedOrdersAndFillableAmounts, + trimmedFeeOrdersAndFillableAmounts, + assetBuyAmount, + feePercentage, + ); + // in order to calculate the maxRate, reverse the ordersAndFillableAmounts such that they are sorted from worst rate to best rate + const maxRate = calculateRate( + reverseOrdersAndFillableAmounts(trimmedOrdersAndFillableAmounts), + reverseOrdersAndFillableAmounts(trimmedFeeOrdersAndFillableAmounts), + assetBuyAmount, + feePercentage, ); - let minEthAmount = constants.ZERO_AMOUNT; - let maxEthAmount = constants.ZERO_AMOUNT; - let cumulativeMakerAmount = constants.ZERO_AMOUNT; - _.forEach(allOrders, (order, index) => { - const remainingFillableMakerAssetAmount = allRemainingAmounts[index]; - const claimableTakerAssetAmount = orderUtils.calculateRemainingTakerAssetAmount( - order, - remainingFillableMakerAssetAmount, - ); - // taker asset is always assumed to be WETH - maxEthAmount = maxEthAmount.plus(claimableTakerAssetAmount); - if (cumulativeMakerAmount.lessThan(assetBuyAmount)) { - minEthAmount = minEthAmount.plus(claimableTakerAssetAmount); - } - cumulativeMakerAmount = cumulativeMakerAmount.plus(remainingFillableMakerAssetAmount); - }); - const feeAdjustedMinRate = minEthAmount.mul(feePercentage + 1).div(assetBuyAmount); - const feeAdjustedMaxRate = minEthAmount.mul(feePercentage + 1).div(assetBuyAmount); return { assetData, orders: resultOrders, feeOrders: resultFeeOrders, - minRate: feeAdjustedMinRate, - maxRate: feeAdjustedMaxRate, + minRate, + maxRate, assetBuyAmount, feePercentage, }; }, }; + +function calculateRate( + ordersAndFillableAmounts: OrdersAndFillableAmounts, + feeOrdersAndFillableAmounts: OrdersAndFillableAmounts, + assetBuyAmount: BigNumber, + feePercentage: number, +): BigNumber { + // find the total eth and zrx needed to buy assetAmount from the resultOrders from left to right (best rate to worst rate) + const [minEthAmountToBuyAsset, minZrxAmountToBuyAsset] = findEthAndZrxAmountNeededToBuyAsset( + ordersAndFillableAmounts, + assetBuyAmount, + ); + // find the total eth needed to buy fees + const minEthAmountToBuyFees = findEthAmountNeededToBuyFees(feeOrdersAndFillableAmounts, minZrxAmountToBuyAsset); + const finalMinEthAmount = minEthAmountToBuyAsset.plus(minEthAmountToBuyFees).mul(feePercentage + 1); + // divide into the assetBuyAmount in order to find rate of makerAsset / WETH + const result = assetBuyAmount.div(finalMinEthAmount); + return result; +} + +// given an OrdersAndFillableAmounts, reverse the orders and remainingFillableMakerAssetAmounts properties +function reverseOrdersAndFillableAmounts(ordersAndFillableAmounts: OrdersAndFillableAmounts): OrdersAndFillableAmounts { + const ordersCopy = _.clone(ordersAndFillableAmounts.orders); + const remainingFillableMakerAssetAmountsCopy = _.clone(ordersAndFillableAmounts.remainingFillableMakerAssetAmounts); + return { + orders: ordersCopy.reverse(), + remainingFillableMakerAssetAmounts: remainingFillableMakerAssetAmountsCopy.reverse(), + }; +} + +function findEthAmountNeededToBuyFees( + feeOrdersAndFillableAmounts: OrdersAndFillableAmounts, + feeAmount: BigNumber, +): BigNumber { + const { orders, remainingFillableMakerAssetAmounts } = feeOrdersAndFillableAmounts; + const result = _.reduce( + orders, + (acc, order, index) => { + const remainingFillableMakerAssetAmount = remainingFillableMakerAssetAmounts[index]; + const amountToFill = BigNumber.min(acc.remainingFeeAmount, remainingFillableMakerAssetAmount); + const feeAdjustedRate = rateUtils.getFeeAdjustedRateOfFeeOrder(order); + const ethAmountForThisOrder = feeAdjustedRate.mul(amountToFill); + return { + ethAmount: acc.ethAmount.plus(ethAmountForThisOrder), + remainingFeeAmount: BigNumber.max(constants.ZERO_AMOUNT, acc.remainingFeeAmount.minus(amountToFill)), + }; + }, + { + ethAmount: constants.ZERO_AMOUNT, + remainingFeeAmount: feeAmount, + }, + ); + return result.ethAmount; +} + +function findEthAndZrxAmountNeededToBuyAsset( + ordersAndFillableAmounts: OrdersAndFillableAmounts, + assetBuyAmount: BigNumber, +): [BigNumber, BigNumber] { + const { orders, remainingFillableMakerAssetAmounts } = ordersAndFillableAmounts; + const result = _.reduce( + orders, + (acc, order, index) => { + const remainingFillableMakerAssetAmount = remainingFillableMakerAssetAmounts[index]; + const amountToFill = BigNumber.min(acc.remainingAssetBuyAmount, remainingFillableMakerAssetAmount); + const ethAmountForThisOrder = amountToFill + .mul(order.takerAssetAmount) + .dividedToIntegerBy(order.makerAssetAmount); + const zrxAmountForThisOrder = amountToFill.mul(order.takerFee).dividedToIntegerBy(order.makerAssetAmount); + return { + ethAmount: acc.ethAmount.plus(ethAmountForThisOrder), + zrxAmount: acc.ethAmount.plus(zrxAmountForThisOrder), + remainingAssetBuyAmount: BigNumber.max( + constants.ZERO_AMOUNT, + acc.remainingAssetBuyAmount.minus(amountToFill), + ), + }; + }, + { + ethAmount: constants.ZERO_AMOUNT, + zrxAmount: constants.ZERO_AMOUNT, + remainingAssetBuyAmount: assetBuyAmount, + }, + ); + return [result.ethAmount, result.zrxAmount]; +} diff --git a/packages/asset-buyer/test/buy_quote_calculator_test.ts b/packages/asset-buyer/test/buy_quote_calculator_test.ts new file mode 100644 index 000000000..2cad1ab05 --- /dev/null +++ b/packages/asset-buyer/test/buy_quote_calculator_test.ts @@ -0,0 +1,130 @@ +import { orderFactory } from '@0xproject/order-utils/lib/src/order_factory'; +import { BigNumber } from '@0xproject/utils'; +import * as chai from 'chai'; +import * as _ from 'lodash'; +import 'mocha'; + +import { AssetBuyerError, OrdersAndFillableAmounts } from '../src/types'; +import { buyQuoteCalculator } from '../src/utils/buy_quote_calculator'; + +import { chaiSetup } from './utils/chai_setup'; + +chaiSetup.configure(); +const expect = chai.expect; + +const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'; +const NULL_BYTES = '0x'; + +// tslint:disable:custom-no-magic-numbers +describe('buyQuoteCalculator', () => { + describe('#calculate', () => { + let ordersAndFillableAmounts: OrdersAndFillableAmounts; + let feeOrdersAndFillableAmounts: OrdersAndFillableAmounts; + beforeEach(() => { + // generate two orders for our desired maker asset + // the first order has a rate of 4 makerAsset / WETH with a takerFee of 200 ZRX and has only 200 / 400 makerAsset units left to fill (half fillable) + // the second order has a rate of 2 makerAsset / WETH with a takerFee of 100 ZRX and has 200 / 200 makerAsset units left to fill (completely fillable) + // generate one order for fees + // the fee order has a rate of 1 ZRX / WETH with no taker fee and has 100 ZRX left to fill (completely fillable) + const firstOrder = orderFactory.createOrder( + NULL_ADDRESS, + new BigNumber(400), + NULL_BYTES, + new BigNumber(100), + NULL_BYTES, + NULL_ADDRESS, + { + takerFee: new BigNumber(200), + }, + ); + const firstRemainingFillAmount = new BigNumber(200); + const secondOrder = orderFactory.createOrder( + NULL_ADDRESS, + new BigNumber(200), + NULL_BYTES, + new BigNumber(100), + NULL_BYTES, + NULL_ADDRESS, + { + takerFee: new BigNumber(100), + }, + ); + const secondRemainingFillAmount = secondOrder.makerAssetAmount; + const signedOrders = _.map([firstOrder, secondOrder], order => { + return { + ...order, + signature: NULL_BYTES, + }; + }); + ordersAndFillableAmounts = { + orders: signedOrders, + remainingFillableMakerAssetAmounts: [firstRemainingFillAmount, secondRemainingFillAmount], + }; + const feeOrder = orderFactory.createOrder( + NULL_ADDRESS, + new BigNumber(100), + NULL_BYTES, + new BigNumber(100), + NULL_BYTES, + NULL_ADDRESS, + ); + const signedFeeOrder = { + ...feeOrder, + signature: NULL_BYTES, + }; + feeOrdersAndFillableAmounts = { + orders: [signedFeeOrder], + remainingFillableMakerAssetAmounts: [signedFeeOrder.makerAssetAmount], + }; + }); + it('should throw if not enough maker asset liquidity', () => { + // we have 400 makerAsset units available to fill but attempt to calculate a quote for 500 makerAsset units + expect(() => + buyQuoteCalculator.calculate( + ordersAndFillableAmounts, + feeOrdersAndFillableAmounts, + new BigNumber(500), + 0, + 0, + ), + ).to.throw(AssetBuyerError.InsufficientAssetLiquidity); + }); + it('should throw if not enough ZRX liquidity', () => { + // we request 300 makerAsset units but the ZRX order is only enough to fill the first order, which only has 200 makerAssetUnits available + expect(() => + buyQuoteCalculator.calculate( + ordersAndFillableAmounts, + feeOrdersAndFillableAmounts, + new BigNumber(300), + 0, + 0, + ), + ).to.throw(AssetBuyerError.InsufficientZrxLiquidity); + }); + it('calculates a correct buyQuote', () => { + // we request 200 makerAsset units which can be filled using the first order + // the first order requires a fee of 100 ZRX from the taker which can be filled by the feeOrder + const assetBuyAmount = new BigNumber(200); + const feePercentage = 0.02; + const slippagePercentage = 0; + const buyQuote = buyQuoteCalculator.calculate( + ordersAndFillableAmounts, + feeOrdersAndFillableAmounts, + assetBuyAmount, + feePercentage, + slippagePercentage, + ); + // test if orders are correct + expect(buyQuote.orders).to.deep.equal([ordersAndFillableAmounts.orders[0]]); + expect(buyQuote.feeOrders).to.deep.equal([feeOrdersAndFillableAmounts.orders[0]]); + // test if rates are correct + const expectedMinEthToFill = new BigNumber(150); + const expectedMinRate = assetBuyAmount.div(expectedMinEthToFill.mul(feePercentage + 1)); + expect(buyQuote.minRate).to.bignumber.equal(expectedMinRate); + // because we have no slippage protection, minRate is equal to maxRate + expect(buyQuote.maxRate).to.bignumber.equal(expectedMinRate); + // test if feePercentage gets passed through + expect(buyQuote.feePercentage).to.equal(feePercentage); + }); + }); +}); |