From 540f92ea81bf48921bea4ddf00336d9e3abf9e99 Mon Sep 17 00:00:00 2001 From: Jacob Evans Date: Mon, 27 Jul 2020 18:27:44 +1000 Subject: [PATCH] feat: Depth Chart API endpoint (#290) * feat: Depth Chart API endpoint * added tests * fix excessive accumulation when multiple prices land in the same bucket * Update to latest asset-swapper * refactor and accumulate sources over buckets --- package.json | 2 +- src/config.ts | 4 +- src/constants.ts | 5 + src/handlers/swap_handlers.ts | 28 ++- src/routers/swap_router.ts | 1 + src/services/swap_service.ts | 56 +++++ src/types.ts | 16 ++ src/utils/market_depth_utils.ts | 249 ++++++++++++++++++++++ test/market_depth_test.ts | 359 ++++++++++++++++++++++++++++++++ yarn.lock | 4 +- 10 files changed, 718 insertions(+), 6 deletions(-) create mode 100644 src/utils/market_depth_utils.ts create mode 100644 test/market_depth_test.ts diff --git a/package.json b/package.json index e35efec75..483546747 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ }, "dependencies": { "@0x/assert": "^3.0.4", - "@0x/asset-swapper": "0xProject/gitpkg-registry#0x-asset-swapper-v4.6.0-9a16f5736", + "@0x/asset-swapper": "0xProject/gitpkg-registry#0x-asset-swapper-v4.6.0-ae2a6fb68", "@0x/connect": "^6.0.4", "@0x/contract-addresses": "^4.11.0", "@0x/contract-wrappers": "^13.7.0", diff --git a/src/config.ts b/src/config.ts index 3b3107bab..1b6d927ca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -330,7 +330,7 @@ export const ASSET_SWAPPER_MARKET_ORDERS_V0_OPTS: Partial feeSchedule: FEE_SCHEDULE_V0, gasSchedule: GAS_SCHEDULE_V0, shouldBatchBridgeOrders: true, - runLimit: 2 ** 13, + runLimit: 2 ** 8, }; export const GAS_SCHEDULE_V1: FeeSchedule = { @@ -356,7 +356,7 @@ export const ASSET_SWAPPER_MARKET_ORDERS_V1_OPTS: Partial feeSchedule: FEE_SCHEDULE_V1, gasSchedule: GAS_SCHEDULE_V1, shouldBatchBridgeOrders: false, - runLimit: 2 ** 13, + runLimit: 2 ** 8, }; export const SAMPLER_OVERRIDES: SamplerOverrides | undefined = (() => { diff --git a/src/constants.ts b/src/constants.ts index e0d530e4b..1b254a6ce 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -89,3 +89,8 @@ export const GST2_WALLET_ADDRESSES = { [ChainId.Kovan]: NULL_ADDRESS, [ChainId.Ganache]: NULL_ADDRESS, }; + +// Market Depth +export const MARKET_DEPTH_MAX_SAMPLES = 50; +export const MARKET_DEPTH_DEFAULT_DISTRIBUTION = 1.05; +export const MARKET_DEPTH_END_PRICE_SLIPPAGE_PERC = 20; diff --git a/src/handlers/swap_handlers.ts b/src/handlers/swap_handlers.ts index e2d9696d0..72498ef24 100644 --- a/src/handlers/swap_handlers.ts +++ b/src/handlers/swap_handlers.ts @@ -4,7 +4,12 @@ import * as express from 'express'; import * as HttpStatus from 'http-status-codes'; import { CHAIN_ID } from '../config'; -import { DEFAULT_QUOTE_SLIPPAGE_PERCENTAGE, SWAP_DOCS_URL } from '../constants'; +import { + DEFAULT_QUOTE_SLIPPAGE_PERCENTAGE, + MARKET_DEPTH_DEFAULT_DISTRIBUTION, + MARKET_DEPTH_MAX_SAMPLES, + SWAP_DOCS_URL, +} from '../constants'; import { InternalServerError, RevertAPIError, @@ -125,6 +130,27 @@ export class SwapHandlers { const records = await this._swapService.getTokenPricesAsync(baseAsset, unitAmount); res.status(HttpStatus.OK).send({ records }); } + + public async getMarketDepthAsync(req: express.Request, res: express.Response): Promise { + const makerToken = getTokenMetadataIfExists(req.query.buyToken as string, CHAIN_ID); + const takerToken = getTokenMetadataIfExists(req.query.sellToken as string, CHAIN_ID); + const response = await this._swapService.calculateMarketDepthAsync({ + buyToken: makerToken, + sellToken: takerToken, + sellAmount: new BigNumber(req.query.sellAmount as string), + // tslint:disable-next-line:radix custom-no-magic-numbers + numSamples: req.query.numSamples ? parseInt(req.query.numSamples as string) : MARKET_DEPTH_MAX_SAMPLES, + sampleDistributionBase: req.query.sampleDistributionBase + ? parseFloat(req.query.sampleDistributionBase as string) + : MARKET_DEPTH_DEFAULT_DISTRIBUTION, + excludedSources: + req.query.excludedSources === undefined + ? [] + : parseUtils.parseStringArrForERC20BridgeSources((req.query.excludedSources as string).split(',')), + }); + res.status(HttpStatus.OK).send({ ...response, buyToken: makerToken, sellToken: takerToken }); + } + private async _calculateSwapQuoteAsync( params: GetSwapQuoteRequestParams, swapVersion: SwapVersion, diff --git a/src/routers/swap_router.ts b/src/routers/swap_router.ts index 32b914a63..132c526a1 100644 --- a/src/routers/swap_router.ts +++ b/src/routers/swap_router.ts @@ -12,6 +12,7 @@ export function createSwapRouter(swapService: SwapService): express.Router { router.get('/v0', asyncHandler(SwapHandlers.rootAsync.bind(SwapHandlers))); router.get('/v0/prices', asyncHandler(handlers.getTokenPricesAsync.bind(handlers))); router.get('/v0/tokens', asyncHandler(handlers.getSwapTokensAsync.bind(handlers))); + router.get('/v0/depth', asyncHandler(handlers.getMarketDepthAsync.bind(handlers))); router.get('/v0/quote', asyncHandler(handlers.getSwapQuoteAsync.bind(handlers, SwapVersion.V0))); router.get('/v0/price', asyncHandler(handlers.getSwapPriceAsync.bind(handlers, SwapVersion.V0))); diff --git a/src/services/swap_service.ts b/src/services/swap_service.ts index 4b6cb7728..2b65fbafe 100644 --- a/src/services/swap_service.ts +++ b/src/services/swap_service.ts @@ -12,6 +12,7 @@ import { SwapQuoteRequestOpts, SwapQuoterOpts } from '@0x/asset-swapper/lib/src/ import { ContractAddresses, getContractAddressesForChainOrThrow } from '@0x/contract-addresses'; import { ERC20TokenContract, WETH9Contract } from '@0x/contract-wrappers'; import { assetDataUtils, SupportedProvider } from '@0x/order-utils'; +import { MarketOperation } from '@0x/types'; import { BigNumber, decodeThrownErrorAsRevertError, RevertError } from '@0x/utils'; import { TxData, Web3Wrapper } from '@0x/web3-wrapper'; import * as _ from 'lodash'; @@ -38,6 +39,8 @@ import { InsufficientFundsError } from '../errors'; import { logger } from '../logger'; import { TokenMetadatasForChains } from '../token_metadatas_for_networks'; import { + BucketedPriceDepth, + CalaculateMarketDepthParams, CalculateSwapQuoteParams, GetSwapQuoteResponse, GetTokenPricesResponse, @@ -46,6 +49,7 @@ import { SwapVersion, TokenMetadata, } from '../types'; +import { marketDepthUtils } from '../utils/market_depth_utils'; import { createResultCache, ResultCache } from '../utils/result_cache'; import { serviceUtils } from '../utils/service_utils'; import { getTokenMetadataIfExists } from '../utils/token_metadata_utils'; @@ -269,6 +273,58 @@ export class SwapService { return prices; } + public async calculateMarketDepthAsync( + params: CalaculateMarketDepthParams, + ): Promise<{ + asks: { depth: BucketedPriceDepth[] }; + bids: { depth: BucketedPriceDepth[] }; + }> { + const { buyToken, sellToken, sellAmount, numSamples, sampleDistributionBase, excludedSources } = params; + const marketDepth = await this._swapQuoter.getBidAskLiquidityForMakerTakerAssetPairAsync( + buyToken.tokenAddress, + sellToken.tokenAddress, + sellAmount, + { + numSamples, + excludedSources: [...(excludedSources || []), ERC20BridgeSource.MultiBridge], + sampleDistributionBase, + }, + ); + + const maxEndSlippagePercentage = 20; + const scalePriceByDecimals = (priceDepth: BucketedPriceDepth[]) => + priceDepth.map(b => ({ + ...b, + price: b.price.times(new BigNumber(10).pow(sellToken.decimals - buyToken.decimals)), + })); + const askDepth = scalePriceByDecimals( + marketDepthUtils.calculateDepthForSide( + marketDepth.asks, + MarketOperation.Sell, + numSamples * 2, + sampleDistributionBase, + maxEndSlippagePercentage, + ), + ); + const bidDepth = scalePriceByDecimals( + marketDepthUtils.calculateDepthForSide( + marketDepth.bids, + MarketOperation.Buy, + numSamples * 2, + sampleDistributionBase, + maxEndSlippagePercentage, + ), + ); + return { + // We're buying buyToken and SELLING sellToken (DAI) (50k) + // Price goes from HIGH to LOW + asks: { depth: askDepth }, + // We're BUYING sellToken (DAI) (50k) and selling buyToken + // Price goes from LOW to HIGH + bids: { depth: bidDepth }, + }; + } + private async _getSwapQuoteForWethAsync( params: CalculateSwapQuoteParams, isUnwrap: boolean, diff --git a/src/types.ts b/src/types.ts index 833dcb5f1..cd99fcadc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -613,4 +613,20 @@ export interface HttpServiceConfig { meshHttpUri?: string; metaTxnRateLimiters?: MetaTransactionRateLimitConfig; } + +export interface CalaculateMarketDepthParams { + buyToken: TokenMetadata; + sellToken: TokenMetadata; + sellAmount: BigNumber; + numSamples: number; + sampleDistributionBase: number; + excludedSources?: ERC20BridgeSource[]; +} + +export interface BucketedPriceDepth { + cumulative: BigNumber; + price: BigNumber; + bucket: number; + bucketTotal: BigNumber; +} // tslint:disable-line:max-file-line-count diff --git a/src/utils/market_depth_utils.ts b/src/utils/market_depth_utils.ts new file mode 100644 index 000000000..f639df448 --- /dev/null +++ b/src/utils/market_depth_utils.ts @@ -0,0 +1,249 @@ +import { BalancerFillData, CurveFillData, FillData, UniswapV2FillData } from '@0x/asset-swapper'; +import { + DexSample, + ERC20BridgeSource, + MarketDepthSide, + NativeFillData, +} from '@0x/asset-swapper/lib/src/utils/market_operation_utils/types'; +import { MarketOperation } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import { Web3Wrapper } from '@0x/web3-wrapper'; +import _ = require('lodash'); + +import { + MARKET_DEPTH_DEFAULT_DISTRIBUTION, + MARKET_DEPTH_END_PRICE_SLIPPAGE_PERC, + MARKET_DEPTH_MAX_SAMPLES, + ZERO, +} from '../constants'; +import { BucketedPriceDepth, TokenMetadata } from '../types'; + +// tslint:disable:custom-no-magic-numbers +const MAX_DECIMALS = 18; +const ONE_HUNDRED_PERC = 100; + +export const marketDepthUtils = { + getBucketPrices: ( + startAmount: BigNumber, + endAmount: BigNumber, + numSamples: number, + sampleDistributionBase: number = 1, + ): BigNumber[] => { + const amount = endAmount.minus(startAmount); + const distribution = [...Array(numSamples)].map((_v, i) => + new BigNumber(sampleDistributionBase).pow(i).decimalPlaces(MAX_DECIMALS), + ); + const stepSizes = distribution.map(d => d.div(BigNumber.sum(...distribution))); + const amounts = stepSizes.map((_s, i) => { + return amount + .times(BigNumber.sum(...[0, ...stepSizes.slice(0, i + 1)])) + .plus(startAmount) + .decimalPlaces(MAX_DECIMALS); + }); + return [startAmount, ...amounts]; + }, + calculateUnitPrice: ( + input: BigNumber, + output: BigNumber, + outputToken: TokenMetadata, + inputToken: TokenMetadata, + ): BigNumber => { + if (output && input && output.isGreaterThan(0)) { + return Web3Wrapper.toUnitAmount(output, outputToken.decimals).dividedBy( + Web3Wrapper.toUnitAmount(input, inputToken.decimals), + ); + } + return ZERO; + }, + getSampleAmountsFromDepthSide: (depthSide: MarketDepthSide): BigNumber[] => { + // Native is not a "sampled" output, here we convert it to be a accumulated sample output + const nativeIndexIfExists = depthSide.findIndex(s => s[0] && s[0].source === ERC20BridgeSource.Native); + // Find an on-chain source which has samples, if possible + const nonNativeIndexIfExists = depthSide.findIndex(s => s[0] && s[0].source !== ERC20BridgeSource.Native); + // If we don't have a on-chain samples, just use the native orders inputs for a super rough guide + const sampleAmounts = + nonNativeIndexIfExists !== -1 + ? depthSide[nonNativeIndexIfExists].map(s => s.input) + : _.uniqBy( + depthSide[nativeIndexIfExists].map(s => s.input), + a => a.toString(), + ); + return sampleAmounts; + }, + sampleNativeOrders: (path: Array>, targetInput: BigNumber): BigNumber => { + const sortedPath = path.sort((a, b) => b.output.dividedBy(b.input).comparedTo(a.output.dividedBy(a.input))); + let totalOutput = ZERO; + let totalInput = ZERO; + for (const fill of sortedPath) { + if (totalInput.gte(targetInput)) { + break; + } + const input = BigNumber.min(targetInput.minus(totalInput), fill.input); + const output = input.times(fill.output.dividedBy(fill.input)).integerValue(); + totalOutput = totalOutput.plus(output); + totalInput = totalInput.plus(input); + } + if (totalInput.isLessThan(targetInput)) { + // TODO do I really want to do this + return ZERO; + } + return totalOutput; + }, + normalizeMarketDepthToSampleOutput: (depthSide: MarketDepthSide): MarketDepthSide => { + // Native is not a "sampled" output, here we convert it to be a accumulated sample output + const nativeIndexIfExists = depthSide.findIndex( + s => s[0] && s[0].source === ERC20BridgeSource.Native && s[0].output, + ); + if (nativeIndexIfExists === -1) { + return depthSide.filter(s => s && s.length > 0); + } + // We should now have [1, 10, 100] sample amounts + const sampleAmounts = marketDepthUtils.getSampleAmountsFromDepthSide(depthSide); + const nativeSamples = sampleAmounts.map(a => ({ + input: a, + output: marketDepthUtils.sampleNativeOrders( + depthSide[nativeIndexIfExists] as Array>, + a, + ), + source: ERC20BridgeSource.Native, + })); + const normalizedDepth = [ + ...depthSide.filter(s => s[0] && s[0].source !== ERC20BridgeSource.Native), + nativeSamples, + ].filter(s => s.length > 0); + return normalizedDepth; + }, + + calculateStartEndBucketPrice: ( + depthSide: MarketDepthSide, + side: MarketOperation, + endSlippagePerc = 20, + ): [BigNumber, BigNumber] => { + const pricesByAmount = depthSide + .map(samples => + samples + .map(s => (!s.output.isZero() ? s.output.dividedBy(s.input).decimalPlaces(MAX_DECIMALS) : ZERO)) + .filter(s => s.isGreaterThan(ZERO)), + ) + .filter(samples => samples.length > 0); + let bestInBracket: BigNumber; + let worstBestInBracket: BigNumber; + if (side === MarketOperation.Sell) { + // Sell we want to sell for a higher price as possible + bestInBracket = BigNumber.max(...pricesByAmount.map(s => BigNumber.max(...s))); + worstBestInBracket = bestInBracket.times((ONE_HUNDRED_PERC - endSlippagePerc) / ONE_HUNDRED_PERC); + } else { + // Buy we want to buy for the lowest price possible + bestInBracket = BigNumber.min(...pricesByAmount.map(s => BigNumber.min(...s))); + worstBestInBracket = bestInBracket.times((ONE_HUNDRED_PERC + endSlippagePerc) / ONE_HUNDRED_PERC); + } + return [bestInBracket, worstBestInBracket]; + }, + + distributeSamplesToBuckets: (depthSide: MarketDepthSide, buckets: BigNumber[], side: MarketOperation) => { + const allocatedBuckets = buckets.map((b, i) => ({ price: b, bucket: i, bucketTotal: ZERO, sources: {} })); + const getBucketId = (price: BigNumber): number => { + return buckets.findIndex(b => + side === MarketOperation.Sell ? price.isGreaterThanOrEqualTo(b) : price.isLessThanOrEqualTo(b), + ); + }; + const sampleToSourceKey = (sample: DexSample): string => { + const source = sample.source; + if (!sample.fillData) { + return source; + } + switch (source) { + case ERC20BridgeSource.Curve: + // tslint:disable-next-line:no-unnecessary-type-assertion + return `${source}:${(sample.fillData as CurveFillData).curve.poolAddress}`; + case ERC20BridgeSource.Balancer: + // tslint:disable-next-line:no-unnecessary-type-assertion + return `${source}:${(sample.fillData as BalancerFillData).poolAddress}`; + case ERC20BridgeSource.UniswapV2: + // tslint:disable-next-line:no-unnecessary-type-assertion + return `${source}:${(sample.fillData as UniswapV2FillData).tokenAddressPath.join('-')}`; + default: + break; + } + return source; + }; + for (const samples of depthSide) { + // Since multiple samples can fall into a bucket we do not want to + // double count them. + // Curve, Balancer etc can have the same source strings but be from different + // pools, so we modify their source string temporarily to attribute + // the different pool + const source = sampleToSourceKey(samples[0]); + for (const sample of samples) { + if (sample.output.isZero()) { + continue; + } + const price = sample.output.dividedBy(sample.input); + const bucketId = getBucketId(price); + if (bucketId === -1) { + // No bucket available so we ignore + continue; + } + const bucket = allocatedBuckets[bucketId]; + // If two samples from the same source have landed in the same bucket, take the latter + bucket.bucketTotal = + bucket.sources[source] && !bucket.sources[source].isZero() + ? bucket.bucketTotal.minus(bucket.sources[source]).plus(sample.output) + : bucket.bucketTotal.plus(sample.output); + bucket.sources[source] = sample.output; + } + } + let totalCumulative = ZERO; + // Normalize the source names back and create a cumulative total + const normalizedCumulativeBuckets = allocatedBuckets.map((b, bucketIndex) => { + totalCumulative = totalCumulative.plus(b.bucketTotal); + // Sum all of the various pools which fall under once source (Balancer, Uniswap, Curve...) + const findLastNonEmptyBucketResult = (source: ERC20BridgeSource) => { + let lastValue = ZERO; + for (let i = bucketIndex - 1; i >= 0; i--) { + const value = allocatedBuckets[i].sources[source]; + if (value && !value.isZero()) { + lastValue = value; + break; + } + } + return lastValue; + }; + for (const key of Object.keys(b.sources)) { + const source = key.split(':')[0]; + if (source !== key && Object.values(ERC20BridgeSource).includes(source as ERC20BridgeSource)) { + // Curve:0xabcd,100 -> Curve,100 + // Add Curve:0abcd to Curve + b.sources[source] = b.sources[source] ? b.sources[source].plus(b.sources[key]) : b.sources[key]; + delete b.sources[key]; + } + } + // Accumulate the sources from the previous bucket + for (const source of Object.values(ERC20BridgeSource)) { + // Add the previous bucket + const previousValue = findLastNonEmptyBucketResult(source); + b.sources[source] = previousValue.plus(b.sources[source] || ZERO); + } + return { ...b, cumulative: totalCumulative, sources: b.sources }; + }); + return normalizedCumulativeBuckets; + }, + + calculateDepthForSide: ( + rawDepthSide: MarketDepthSide, + side: MarketOperation, + numBuckets: number = MARKET_DEPTH_MAX_SAMPLES, + bucketDistribution: number = MARKET_DEPTH_DEFAULT_DISTRIBUTION, + maxEndSlippagePercentage: number = MARKET_DEPTH_END_PRICE_SLIPPAGE_PERC, + ): BucketedPriceDepth[] => { + const depthSide = marketDepthUtils.normalizeMarketDepthToSampleOutput(rawDepthSide); + const [startPrice, endPrice] = marketDepthUtils.calculateStartEndBucketPrice( + depthSide, + side, + maxEndSlippagePercentage, + ); + const buckets = marketDepthUtils.getBucketPrices(startPrice, endPrice, numBuckets, bucketDistribution); + const distributedBuckets = marketDepthUtils.distributeSamplesToBuckets(depthSide, buckets, side); + return distributedBuckets; + }, +}; diff --git a/test/market_depth_test.ts b/test/market_depth_test.ts new file mode 100644 index 000000000..bc468cdac --- /dev/null +++ b/test/market_depth_test.ts @@ -0,0 +1,359 @@ +import { ERC20BridgeSource } from '@0x/asset-swapper'; +import { expect } from '@0x/contracts-test-utils'; +import { MarketOperation } from '@0x/types'; +import { BigNumber } from '@0x/utils'; +import 'mocha'; + +import { ZERO } from '../src/constants'; +import { marketDepthUtils } from '../src/utils/market_depth_utils'; + +const B = v => new BigNumber(v); + +// tslint:disable:custom-no-magic-numbers + +const SUITE_NAME = 'market depth utils'; +describe(SUITE_NAME, () => { + describe('getBucketPrices', () => { + it('returns a range from start to end', async () => { + const start = B('1'); + const end = B('123'); + const num = 10; + const range = marketDepthUtils.getBucketPrices(start, end, num); + expect(range[0]).to.be.bignumber.eq('1'); + expect(range[10]).to.be.bignumber.eq('123'); + expect(range.length).to.be.eq(11); + }); + it('can go from high to low', async () => { + const start = B('123'); + const end = B('1'); + const num = 10; + const range = marketDepthUtils.getBucketPrices(start, end, num); + expect(range[0]).to.be.bignumber.eq('123'); + expect(range[10]).to.be.bignumber.eq('1'); + expect(range.length).to.be.eq(11); + }); + }); + describe('getSampleAmountsFromDepthSide', () => { + it('plucks out the input sample amounts', async () => { + const defaultSample = { output: B(10), source: ERC20BridgeSource.Uniswap }; + const sampleAmounts = marketDepthUtils.getSampleAmountsFromDepthSide([ + [ + { ...defaultSample, input: B(1) }, + { ...defaultSample, input: B(2) }, + ], + ]); + expect(sampleAmounts).to.deep.include(B(1)); + expect(sampleAmounts).to.deep.include(B(2)); + }); + it('ignores Native results if they are present', async () => { + const defaultSample = { output: B(10), source: ERC20BridgeSource.Uniswap }; + const nativeSample = { output: B(10), source: ERC20BridgeSource.Native }; + const sampleAmounts = marketDepthUtils.getSampleAmountsFromDepthSide([ + [{ ...defaultSample, input: B(1) }], + [ + { ...nativeSample, input: B(1) }, + { ...nativeSample, input: B(2) }, + ], + ]); + expect(sampleAmounts).to.deep.include(B(1)); + expect(sampleAmounts).to.not.deep.include(B(2)); + }); + it('plucks Native results if it has to', async () => { + const nativeSample = { output: B(10), source: ERC20BridgeSource.Native }; + const sampleAmounts = marketDepthUtils.getSampleAmountsFromDepthSide([ + [ + { ...nativeSample, input: B(1) }, + { ...nativeSample, input: B(2) }, + ], + ]); + expect(sampleAmounts).to.deep.include(B(1)); + expect(sampleAmounts).to.deep.include(B(2)); + }); + }); + describe('sampleNativeOrders', () => { + it('can partially fill a sample amount', async () => { + const nativePath = [{ input: B(100), output: B(200), source: ERC20BridgeSource.Native }]; + const output = marketDepthUtils.sampleNativeOrders(nativePath, B(10)); + expect(output).to.be.bignumber.eq(B(20)); + }); + it('returns zero if it cannot fully fill the amount', async () => { + const nativePath = [{ input: B(100), output: B(200), source: ERC20BridgeSource.Native }]; + const output = marketDepthUtils.sampleNativeOrders(nativePath, B(101)); + expect(output).to.be.bignumber.eq(ZERO); + }); + it('runs across multiple orders', async () => { + const nativePath = [ + { input: B(50), output: B(200), source: ERC20BridgeSource.Native }, + { input: B(50), output: B(50), source: ERC20BridgeSource.Native }, + ]; + const output = marketDepthUtils.sampleNativeOrders(nativePath, B(100)); + expect(output).to.be.bignumber.eq(B(250)); + }); + }); + describe('normalizeMarketDepthToSampleOutput', () => { + it('converts raw orders into samples for Native', async () => { + const nativePath = [ + { input: B(50), output: B(200), source: ERC20BridgeSource.Native }, + { input: B(50), output: B(50), source: ERC20BridgeSource.Native }, + ]; + const uniPath = [ + { input: B(1), output: B(10), source: ERC20BridgeSource.Uniswap }, + { input: B(2), output: B(20), source: ERC20BridgeSource.Uniswap }, + ]; + const results = marketDepthUtils.normalizeMarketDepthToSampleOutput([uniPath, nativePath]); + expect(results).to.deep.include(uniPath); + expect(results).to.deep.include([ + { input: B(1), output: B(4), source: ERC20BridgeSource.Native }, + { input: B(2), output: B(8), source: ERC20BridgeSource.Native }, + ]); + }); + }); + describe('calculateStartEndBucketPrice', () => { + const nativePath = [ + { input: B(1), output: B(4), source: ERC20BridgeSource.Native }, + { input: B(2), output: B(8), source: ERC20BridgeSource.Native }, + ]; + const uniPath = [ + { input: B(1), output: B(10), source: ERC20BridgeSource.Uniswap }, + { input: B(2), output: B(20), source: ERC20BridgeSource.Uniswap }, + ]; + describe('sell', () => { + it('starts at the best (highest) price and ends perc lower', async () => { + const [start, end] = marketDepthUtils.calculateStartEndBucketPrice( + [nativePath, uniPath], + MarketOperation.Sell, + 20, + ); + // Best price is the uniswap 1 for 10 + expect(start).to.be.bignumber.eq(B(10)); + expect(end).to.be.bignumber.eq(start.times(0.8)); + }); + }); + describe('buy', () => { + it('starts at the best (lowest) price and ends perc higher', async () => { + const [start, end] = marketDepthUtils.calculateStartEndBucketPrice( + [nativePath, uniPath], + MarketOperation.Buy, + 20, + ); + // Best price is the native 4 to receive 1 + expect(start).to.be.bignumber.eq(B(4)); + expect(end).to.be.bignumber.eq(start.times(1.2)); + }); + }); + }); + describe('distributeSamplesToBuckets', () => { + const nativePath = [ + { input: B(1), output: B(4), source: ERC20BridgeSource.Native }, + { input: B(2), output: B(8), source: ERC20BridgeSource.Native }, + ]; + const uniPath = [ + { input: B(1), output: B(10), source: ERC20BridgeSource.Uniswap }, + { input: B(2), output: B(20), source: ERC20BridgeSource.Uniswap }, + ]; + describe('sell', () => { + it('allocates the samples to the right bucket by price', async () => { + const buckets = [B(10), B(8), B(4), B(1)]; + const allocated = marketDepthUtils.distributeSamplesToBuckets( + [nativePath, uniPath], + buckets, + MarketOperation.Sell, + ); + const [first, second, third, fourth] = allocated; + expect(first.cumulative).to.be.bignumber.eq(20); + expect(first.bucketTotal).to.be.bignumber.eq(20); + expect(first.bucket).to.be.eq(0); + expect(first.price).to.be.bignumber.eq(10); + expect(first.sources[ERC20BridgeSource.Uniswap]).to.be.bignumber.eq(20); + + expect(second.cumulative).to.be.bignumber.eq(20); + expect(second.bucketTotal).to.be.bignumber.eq(0); + expect(second.bucket).to.be.eq(1); + expect(second.price).to.be.bignumber.eq(8); + expect(second.sources[ERC20BridgeSource.Uniswap]).to.be.bignumber.eq(20); + + expect(third.cumulative).to.be.bignumber.eq(28); + expect(third.bucketTotal).to.be.bignumber.eq(8); + expect(third.bucket).to.be.eq(2); + expect(third.price).to.be.bignumber.eq(4); + expect(third.sources[ERC20BridgeSource.Native]).to.be.bignumber.eq(8); + expect(third.sources[ERC20BridgeSource.Uniswap]).to.be.bignumber.eq(20); + + expect(fourth.cumulative).to.be.bignumber.eq(28); + expect(fourth.bucketTotal).to.be.bignumber.eq(0); + expect(fourth.bucket).to.be.eq(3); + expect(fourth.price).to.be.bignumber.eq(1); + expect(fourth.sources[ERC20BridgeSource.Native]).to.be.bignumber.eq(8); + expect(fourth.sources[ERC20BridgeSource.Uniswap]).to.be.bignumber.eq(20); + }); + it('does not allocate to a bucket if there is none available', async () => { + const buckets = [B(10)]; + const badSource = [{ input: B(1), output: B(5), source: ERC20BridgeSource.Uniswap }]; + const allocated = marketDepthUtils.distributeSamplesToBuckets( + [badSource], + buckets, + MarketOperation.Sell, + ); + const [first] = allocated; + expect(first.cumulative).to.be.bignumber.eq(0); + expect(first.bucketTotal).to.be.bignumber.eq(0); + expect(first.bucket).to.be.eq(0); + expect(first.price).to.be.bignumber.eq(10); + expect(first.sources[ERC20BridgeSource.Uniswap]).to.be.bignumber.eq(0); + }); + }); + describe('buy', () => { + it('allocates the samples to the right bucket by price', async () => { + const buckets = [B(1), B(4), B(10)]; + const allocated = marketDepthUtils.distributeSamplesToBuckets( + [nativePath, uniPath], + buckets, + MarketOperation.Buy, + ); + const [first, second, third] = allocated; + expect(first.cumulative).to.be.bignumber.eq(0); + expect(first.bucketTotal).to.be.bignumber.eq(0); + expect(first.bucket).to.be.eq(0); + expect(first.price).to.be.bignumber.eq(1); + + expect(second.cumulative).to.be.bignumber.eq(8); + expect(second.bucketTotal).to.be.bignumber.eq(8); + expect(second.bucket).to.be.eq(1); + expect(second.price).to.be.bignumber.eq(4); + expect(second.sources[ERC20BridgeSource.Native]).to.be.bignumber.eq(8); + + expect(third.cumulative).to.be.bignumber.eq(28); + expect(third.bucketTotal).to.be.bignumber.eq(20); + expect(third.bucket).to.be.eq(2); + expect(third.price).to.be.bignumber.eq(10); + expect(third.sources[ERC20BridgeSource.Uniswap]).to.be.bignumber.eq(20); + expect(third.sources[ERC20BridgeSource.Native]).to.be.bignumber.eq(8); + }); + }); + }); + describe('calculateDepthForSide', () => { + // Essentially orders not samples + const nativePath = [{ input: B(10), output: B(80), source: ERC20BridgeSource.Native }]; + it('calculates prices and allocates into buckets. Partial 0x', async () => { + const dexPaths = [ + [ + { input: B(1), output: B(10), source: ERC20BridgeSource.Uniswap }, + { input: B(2), output: B(11), source: ERC20BridgeSource.Uniswap }, + ], + [ + { input: B(1), output: B(0), source: ERC20BridgeSource.Curve }, + { input: B(2), output: B(0), source: ERC20BridgeSource.Curve }, + ], + ]; + const result = marketDepthUtils.calculateDepthForSide( + [nativePath, ...dexPaths], + MarketOperation.Sell, + 4, // buckets + 1, // distribution + 20, // max end perc + ); + const emptySources = {}; + Object.values(ERC20BridgeSource).forEach(s => (emptySources[s] = ZERO)); + expect(result).to.be.deep.eq([ + { + price: B(10), + bucket: 0, + bucketTotal: B(10), + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + { + price: B(9.5), + bucket: 1, + bucketTotal: ZERO, + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + { + price: B(9), + bucket: 2, + bucketTotal: ZERO, + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + { + price: B(8.5), + bucket: 3, + bucketTotal: ZERO, + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + // Native is the sample for the sample for 2 (16) (overriding thhe 1 sample), since we didn't sample for 10 it does + // not contain the entire order + { + price: B(8), + bucket: 4, + bucketTotal: B(16), + cumulative: B(26), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10), [ERC20BridgeSource.Native]: B(16) }, + }, + ]); + }); + + it('calculates prices and allocates into buckets. Partial Uni', async () => { + const dexPaths = [ + [ + { input: B(1), output: B(10), source: ERC20BridgeSource.Uniswap }, + { input: B(2), output: B(11), source: ERC20BridgeSource.Uniswap }, + { input: B(10), output: B(0), source: ERC20BridgeSource.Uniswap }, + ], + [ + { input: B(1), output: B(0), source: ERC20BridgeSource.Curve }, + { input: B(2), output: B(0), source: ERC20BridgeSource.Curve }, + { input: B(10), output: B(0), source: ERC20BridgeSource.Curve }, + ], + ]; + const result = marketDepthUtils.calculateDepthForSide( + [nativePath, ...dexPaths], + MarketOperation.Sell, + 4, // buckets + 1, // distribution + 20, // max end perc + ); + const emptySources = {}; + Object.values(ERC20BridgeSource).forEach(s => (emptySources[s] = ZERO)); + expect(result).to.be.deep.eq([ + { + price: B(10), + bucket: 0, + bucketTotal: B(10), + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + { + price: B(9.5), + bucket: 1, + bucketTotal: ZERO, + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + { + price: B(9), + bucket: 2, + bucketTotal: ZERO, + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + { + price: B(8.5), + bucket: 3, + bucketTotal: ZERO, + cumulative: B(10), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10) }, + }, + { + price: B(8), + bucket: 4, + bucketTotal: B(80), + cumulative: B(90), + sources: { ...emptySources, [ERC20BridgeSource.Uniswap]: B(10), [ERC20BridgeSource.Native]: B(80) }, + }, + ]); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index bb5733fea..1a6b4db36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,9 +32,9 @@ lodash "^4.17.11" valid-url "^1.0.9" -"@0x/asset-swapper@0xProject/gitpkg-registry#0x-asset-swapper-v4.6.0-9a16f5736": +"@0x/asset-swapper@0xProject/gitpkg-registry#0x-asset-swapper-v4.6.0-ae2a6fb68": version "4.6.0" - resolved "https://codeload.github.com/0xProject/gitpkg-registry/tar.gz/3a630c5dc216a3d5eaa6b2789658a5f3eb5fd4dd" + resolved "https://codeload.github.com/0xProject/gitpkg-registry/tar.gz/209c8324412173f5aa1c4b65fbe5494ea648205e" dependencies: "@0x/assert" "^3.0.9" "@0x/contract-addresses" "^4.11.0"