asset-swapper: Quicker path-finding (#2640)

* `@0x/asset-swapper`: Speed up path optimizer.

* `@0x/asset-swapper`: address my own review comment

* `@0x/asset-swapper`: Update changelog

Co-authored-by: Lawrence Forman <me@merklejerk.com>
Co-authored-by: Jacob Evans <jacob@dekz.net>
This commit is contained in:
Lawrence Forman 2020-07-27 01:09:49 -04:00 committed by GitHub
parent 5afe2616a4
commit ae2a6fb685
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 65 additions and 70 deletions

View File

@ -18,6 +18,10 @@
"note": "Support more varied curves", "note": "Support more varied curves",
"pr": 2633 "pr": 2633
}, },
{
"note": "Make path optimization go faster",
"pr": 2640
},
{ {
"note": "Adds `getBidAskLiquidityForMakerTakerAssetPairAsync` to return more detailed sample information", "note": "Adds `getBidAskLiquidityForMakerTakerAssetPairAsync` to return more detailed sample information",
"pr": 2641 "pr": 2641

View File

@ -1,4 +1,4 @@
import { BigNumber } from '@0x/utils'; import { BigNumber, hexUtils } from '@0x/utils';
import { MarketOperation, SignedOrderWithFillableAmounts } from '../../types'; import { MarketOperation, SignedOrderWithFillableAmounts } from '../../types';
import { fillableAmountsUtils } from '../../utils/fillable_amounts_utils'; import { fillableAmountsUtils } from '../../utils/fillable_amounts_utils';
@ -56,6 +56,7 @@ function nativeOrdersToPath(
ethToOutputRate: BigNumber, ethToOutputRate: BigNumber,
fees: FeeSchedule, fees: FeeSchedule,
): Fill[] { ): Fill[] {
const sourcePathId = hexUtils.random();
// Create a single path from all orders. // Create a single path from all orders.
let path: Fill[] = []; let path: Fill[] = [];
for (const order of orders) { for (const order of orders) {
@ -84,6 +85,7 @@ function nativeOrdersToPath(
continue; continue;
} }
path.push({ path.push({
sourcePathId,
input: clippedInput, input: clippedInput,
output: clippedOutput, output: clippedOutput,
rate, rate,
@ -114,6 +116,7 @@ function dexQuotesToPaths(
): Fill[][] { ): Fill[][] {
const paths: Fill[][] = []; const paths: Fill[][] = [];
for (let quote of dexQuotes) { for (let quote of dexQuotes) {
const sourcePathId = hexUtils.random();
const path: Fill[] = []; const path: Fill[] = [];
// Drop any non-zero entries. This can occur if the any fills on Kyber were UniswapReserves // Drop any non-zero entries. This can occur if the any fills on Kyber were UniswapReserves
// We need not worry about Kyber fills going to UniswapReserve as the input amount // We need not worry about Kyber fills going to UniswapReserve as the input amount
@ -136,6 +139,7 @@ function dexQuotesToPaths(
const adjustedRate = side === MarketOperation.Sell ? adjustedOutput.div(input) : input.div(adjustedOutput); const adjustedRate = side === MarketOperation.Sell ? adjustedOutput.div(input) : input.div(adjustedOutput);
path.push({ path.push({
sourcePathId,
input, input,
output, output,
rate, rate,
@ -261,35 +265,12 @@ export function collapsePath(path: Fill[]): CollapsedFill[] {
return collapsed; return collapsed;
} }
export function getFallbackSourcePaths(optimalPath: Fill[], allPaths: Fill[][]): Fill[][] {
const optimalSources: ERC20BridgeSource[] = [];
for (const fill of optimalPath) {
if (!optimalSources.includes(fill.source)) {
optimalSources.push(fill.source);
}
}
const fallbackPaths: Fill[][] = [];
for (const path of allPaths) {
if (optimalSources.includes(path[0].source)) {
continue;
}
// HACK(dorothy-zbornak): We *should* be filtering out paths that
// conflict with the optimal path (i.e., Kyber conflicts), but in
// practice we often end up not being able to find a fallback path
// because we've lost 2 major liquiduty sources. The end result is
// we end up with many more reverts than what would be actually caused
// by conflicts.
fallbackPaths.push(path);
}
return fallbackPaths;
}
export function getPathAdjustedRate(side: MarketOperation, path: Fill[], targetInput: BigNumber): BigNumber { export function getPathAdjustedRate(side: MarketOperation, path: Fill[], targetInput: BigNumber): BigNumber {
const [input, output] = getPathAdjustedSize(path, targetInput); const [, output] = getPathAdjustedSize(path, targetInput);
if (input.eq(0) || output.eq(0)) { if (output.eq(0)) {
return ZERO_AMOUNT; return ZERO_AMOUNT;
} }
return side === MarketOperation.Sell ? output.div(input) : input.div(output); return side === MarketOperation.Sell ? output.div(targetInput) : targetInput.div(output);
} }
export function getPathAdjustedSlippage( export function getPathAdjustedSlippage(

View File

@ -3,16 +3,12 @@ import { BigNumber } from '@0x/utils';
import { MarketOperation } from '../../types'; import { MarketOperation } from '../../types';
import { ZERO_AMOUNT } from './constants'; import { ZERO_AMOUNT } from './constants';
import { getPathAdjustedSize, getPathSize, isValidPath } from './fills'; import { getPathAdjustedRate, getPathSize, isValidPath } from './fills';
import { Fill } from './types'; import { Fill } from './types';
// tslint:disable: prefer-for-of custom-no-magic-numbers completed-docs // tslint:disable: prefer-for-of custom-no-magic-numbers completed-docs
const RUN_LIMIT_DECAY_FACTOR = 0.8; const RUN_LIMIT_DECAY_FACTOR = 0.5;
// Used to yield the event loop when performing CPU intensive tasks
// tislint:disable-next-line:no-inferred-empty-object-type
const setImmediateAsync = async (delay: number = 0) =>
new Promise<void>(resolve => setImmediate(() => resolve(), delay));
/** /**
* Find the optimal mixture of paths that maximizes (for sells) or minimizes * Find the optimal mixture of paths that maximizes (for sells) or minimizes
@ -22,16 +18,19 @@ export async function findOptimalPathAsync(
side: MarketOperation, side: MarketOperation,
paths: Fill[][], paths: Fill[][],
targetInput: BigNumber, targetInput: BigNumber,
runLimit: number = 2 ** 15, runLimit: number = 2 ** 8,
): Promise<Fill[] | undefined> { ): Promise<Fill[] | undefined> {
// Sort paths in descending order by adjusted output amount. // Sort paths by descending adjusted rate.
const sortedPaths = paths const sortedPaths = paths
.slice(0) .slice(0)
.sort((a, b) => getPathAdjustedSize(b, targetInput)[1].comparedTo(getPathAdjustedSize(a, targetInput)[1])); .sort((a, b) =>
getPathAdjustedRate(side, b, targetInput).comparedTo(getPathAdjustedRate(side, a, targetInput)),
);
let optimalPath = sortedPaths[0] || []; let optimalPath = sortedPaths[0] || [];
for (const [i, path] of sortedPaths.slice(1).entries()) { for (const [i, path] of sortedPaths.slice(1).entries()) {
optimalPath = mixPaths(side, optimalPath, path, targetInput, runLimit * RUN_LIMIT_DECAY_FACTOR ** i); optimalPath = mixPaths(side, optimalPath, path, targetInput, runLimit * RUN_LIMIT_DECAY_FACTOR ** i);
await setImmediateAsync(); // Yield to event loop.
await Promise.resolve();
} }
return isPathComplete(optimalPath, targetInput) ? optimalPath : undefined; return isPathComplete(optimalPath, targetInput) ? optimalPath : undefined;
} }
@ -43,9 +42,10 @@ function mixPaths(
targetInput: BigNumber, targetInput: BigNumber,
maxSteps: number, maxSteps: number,
): Fill[] { ): Fill[] {
let bestPath: Fill[] = []; const _maxSteps = Math.max(maxSteps, 16);
let bestPathInput = ZERO_AMOUNT; let bestPath: Fill[] = pathA;
let bestPathRate = ZERO_AMOUNT; let bestPathInput = getPathSize(pathA, targetInput)[0];
let bestPathRate = getPathAdjustedRate(side, pathA, targetInput);
let steps = 0; let steps = 0;
const _isBetterPath = (input: BigNumber, rate: BigNumber) => { const _isBetterPath = (input: BigNumber, rate: BigNumber) => {
if (bestPathInput.lt(targetInput)) { if (bestPathInput.lt(targetInput)) {
@ -57,7 +57,7 @@ function mixPaths(
}; };
const _walk = (path: Fill[], input: BigNumber, output: BigNumber, allFills: Fill[]) => { const _walk = (path: Fill[], input: BigNumber, output: BigNumber, allFills: Fill[]) => {
steps += 1; steps += 1;
const rate = getRate(side, input, output); const rate = getRate(side, targetInput, output);
if (_isBetterPath(input, rate)) { if (_isBetterPath(input, rate)) {
bestPath = path; bestPath = path;
bestPathInput = input; bestPathInput = input;
@ -65,13 +65,11 @@ function mixPaths(
} }
const remainingInput = targetInput.minus(input); const remainingInput = targetInput.minus(input);
if (remainingInput.gt(0)) { if (remainingInput.gt(0)) {
for (let i = 0; i < allFills.length; ++i) { for (let i = 0; i < allFills.length && steps < _maxSteps; ++i) {
const fill = allFills[i]; const fill = allFills[i];
if (steps + 1 >= maxSteps) { const nextPath = [...path, fill];
break; // Only walk valid paths.
} if (!isValidPath(nextPath, true)) {
const childPath = [...path, fill];
if (!isValidPath(childPath, true)) {
continue; continue;
} }
// Remove this fill from the next list of candidate fills. // Remove this fill from the next list of candidate fills.
@ -79,7 +77,7 @@ function mixPaths(
nextAllFills.splice(i, 1); nextAllFills.splice(i, 1);
// Recurse. // Recurse.
_walk( _walk(
childPath, nextPath,
input.plus(BigNumber.min(remainingInput, fill.input)), input.plus(BigNumber.min(remainingInput, fill.input)),
output.plus( output.plus(
// Clip the output of the next fill to the remaining // Clip the output of the next fill to the remaining
@ -91,7 +89,27 @@ function mixPaths(
} }
} }
}; };
_walk(bestPath, ZERO_AMOUNT, ZERO_AMOUNT, [...pathA, ...pathB].sort((a, b) => b.rate.comparedTo(a.rate))); const allPaths = [...pathA, ...pathB];
const sources = allPaths.filter(f => f.index === 0).map(f => f.sourcePathId);
const rateBySource = Object.assign(
{},
...sources.map(s => ({
[s]: getPathAdjustedRate(side, allPaths.filter(f => f.sourcePathId === s), targetInput),
})),
);
_walk(
[],
ZERO_AMOUNT,
ZERO_AMOUNT,
// Sort subpaths by rate and keep fills contiguous to improve our
// chances of walking ideal, valid paths first.
allPaths.sort((a, b) => {
if (a.sourcePathId !== b.sourcePathId) {
return rateBySource[b.sourcePathId].comparedTo(rateBySource[a.sourcePathId]);
}
return a.index - b.index;
}),
);
return bestPath; return bestPath;
} }

View File

@ -120,6 +120,10 @@ export enum FillFlags {
* Represents a node on a fill path. * Represents a node on a fill path.
*/ */
export interface Fill<TFillData extends FillData = FillData> { export interface Fill<TFillData extends FillData = FillData> {
// Unique ID of the original source path this fill belongs to.
// This is generated when the path is generated and is useful to distinguish
// paths that have the same `source` IDs but are distinct (e.g., Curves).
sourcePathId: string;
// See `FillFlags`. // See `FillFlags`.
flags: FillFlags; flags: FillFlags;
// Input fill amount (taker asset amount in a sell, maker asset amount in a buy). // Input fill amount (taker asset amount in a sell, maker asset amount in a buy).

View File

@ -703,15 +703,9 @@ describe('MarketOperationUtils tests', () => {
); );
const improvedOrders = improvedOrdersResponse.optimizedOrders; const improvedOrders = improvedOrdersResponse.optimizedOrders;
const orderSources = improvedOrders.map(o => o.fills[0].source); const orderSources = improvedOrders.map(o => o.fills[0].source);
const firstSources = [ const firstSources = orderSources.slice(0, 4);
ERC20BridgeSource.Native, const secondSources = orderSources.slice(4);
ERC20BridgeSource.Native, expect(_.intersection(firstSources, secondSources)).to.be.length(0);
ERC20BridgeSource.Native,
ERC20BridgeSource.Uniswap,
];
const secondSources = [ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Kyber];
expect(orderSources.slice(0, firstSources.length).sort()).to.deep.eq(firstSources.sort());
expect(orderSources.slice(firstSources.length).sort()).to.deep.eq(secondSources.sort());
}); });
it('does not create a fallback if below maxFallbackSlippage', async () => { it('does not create a fallback if below maxFallbackSlippage', async () => {
@ -819,9 +813,9 @@ describe('MarketOperationUtils tests', () => {
expect(improvedOrders).to.be.length(3); expect(improvedOrders).to.be.length(3);
const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source)); const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source));
expect(orderFillSources).to.deep.eq([ expect(orderFillSources).to.deep.eq([
[ERC20BridgeSource.Uniswap], [ERC20BridgeSource.Uniswap, ERC20BridgeSource.Curve],
[ERC20BridgeSource.Native], [ERC20BridgeSource.Native],
[ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Curve], [ERC20BridgeSource.Eth2Dai],
]); ]);
}); });
}); });
@ -1091,15 +1085,9 @@ describe('MarketOperationUtils tests', () => {
); );
const improvedOrders = improvedOrdersResponse.optimizedOrders; const improvedOrders = improvedOrdersResponse.optimizedOrders;
const orderSources = improvedOrders.map(o => o.fills[0].source); const orderSources = improvedOrders.map(o => o.fills[0].source);
const firstSources = [ const firstSources = orderSources.slice(0, 4);
ERC20BridgeSource.Native, const secondSources = orderSources.slice(4);
ERC20BridgeSource.Native, expect(_.intersection(firstSources, secondSources)).to.be.length(0);
ERC20BridgeSource.Native,
ERC20BridgeSource.Uniswap,
];
const secondSources = [ERC20BridgeSource.Eth2Dai];
expect(orderSources.slice(0, firstSources.length).sort()).to.deep.eq(firstSources.sort());
expect(orderSources.slice(firstSources.length).sort()).to.deep.eq(secondSources.sort());
}); });
it('does not create a fallback if below maxFallbackSlippage', async () => { it('does not create a fallback if below maxFallbackSlippage', async () => {
@ -1145,7 +1133,7 @@ describe('MarketOperationUtils tests', () => {
const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source)); const orderFillSources = improvedOrders.map(o => o.fills.map(f => f.source));
expect(orderFillSources).to.deep.eq([ expect(orderFillSources).to.deep.eq([
[ERC20BridgeSource.Native], [ERC20BridgeSource.Native],
[ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap], [ERC20BridgeSource.Uniswap, ERC20BridgeSource.Eth2Dai],
]); ]);
}); });
}); });