asset-swapper: Fix optimization of buy paths (#2655)
* `@0x/asset-swapper`: Fix optimization of buy paths * `@0x/asset-swapper`: Fix optimization of buy paths * `@0x/asset-swapper`: Optimize the optimizer. * `@0x/asset-swapper`: Remove unused `Fill` fields Co-authored-by: Lawrence Forman <me@merklejerk.com>
This commit is contained in:
parent
5f47ad3363
commit
c03f1586e6
@ -33,6 +33,10 @@
|
|||||||
{
|
{
|
||||||
"note": "Add support for buy token affiliate fees",
|
"note": "Add support for buy token affiliate fees",
|
||||||
"pr": 2658
|
"pr": 2658
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"note": "Fix optimization of buy paths",
|
||||||
|
"pr": 2655
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -58,7 +58,7 @@ function nativeOrdersToPath(
|
|||||||
): Fill[] {
|
): Fill[] {
|
||||||
const sourcePathId = hexUtils.random();
|
const sourcePathId = hexUtils.random();
|
||||||
// Create a single path from all orders.
|
// Create a single path from all orders.
|
||||||
let path: Fill[] = [];
|
let path: Array<Fill & { adjustedRate: BigNumber }> = [];
|
||||||
for (const order of orders) {
|
for (const order of orders) {
|
||||||
const makerAmount = fillableAmountsUtils.getMakerAssetAmountSwappedAfterOrderFees(order);
|
const makerAmount = fillableAmountsUtils.getMakerAssetAmountSwappedAfterOrderFees(order);
|
||||||
const takerAmount = fillableAmountsUtils.getTakerAssetAmountSwappedAfterOrderFees(order);
|
const takerAmount = fillableAmountsUtils.getTakerAssetAmountSwappedAfterOrderFees(order);
|
||||||
@ -67,7 +67,6 @@ function nativeOrdersToPath(
|
|||||||
const penalty = ethToOutputRate.times(
|
const penalty = ethToOutputRate.times(
|
||||||
fees[ERC20BridgeSource.Native] === undefined ? 0 : fees[ERC20BridgeSource.Native]!(),
|
fees[ERC20BridgeSource.Native] === undefined ? 0 : fees[ERC20BridgeSource.Native]!(),
|
||||||
);
|
);
|
||||||
const rate = makerAmount.div(takerAmount);
|
|
||||||
// targetInput can be less than the order size
|
// targetInput can be less than the order size
|
||||||
// whilst the penalty is constant, it affects the adjusted output
|
// whilst the penalty is constant, it affects the adjusted output
|
||||||
// only up until the target has been exhausted.
|
// only up until the target has been exhausted.
|
||||||
@ -86,11 +85,10 @@ function nativeOrdersToPath(
|
|||||||
}
|
}
|
||||||
path.push({
|
path.push({
|
||||||
sourcePathId,
|
sourcePathId,
|
||||||
input: clippedInput,
|
|
||||||
output: clippedOutput,
|
|
||||||
rate,
|
|
||||||
adjustedRate,
|
adjustedRate,
|
||||||
adjustedOutput,
|
adjustedOutput,
|
||||||
|
input: clippedInput,
|
||||||
|
output: clippedOutput,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
index: 0, // TBD
|
index: 0, // TBD
|
||||||
parent: undefined, // TBD
|
parent: undefined, // TBD
|
||||||
@ -135,15 +133,11 @@ function dexQuotesToPaths(
|
|||||||
? ethToOutputRate.times(fee)
|
? ethToOutputRate.times(fee)
|
||||||
: ZERO_AMOUNT;
|
: ZERO_AMOUNT;
|
||||||
const adjustedOutput = side === MarketOperation.Sell ? output.minus(penalty) : output.plus(penalty);
|
const adjustedOutput = side === MarketOperation.Sell ? output.minus(penalty) : output.plus(penalty);
|
||||||
const rate = side === MarketOperation.Sell ? output.div(input) : input.div(output);
|
|
||||||
const adjustedRate = side === MarketOperation.Sell ? adjustedOutput.div(input) : input.div(adjustedOutput);
|
|
||||||
|
|
||||||
path.push({
|
path.push({
|
||||||
sourcePathId,
|
sourcePathId,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
rate,
|
|
||||||
adjustedRate,
|
|
||||||
adjustedOutput,
|
adjustedOutput,
|
||||||
source,
|
source,
|
||||||
fillData,
|
fillData,
|
||||||
@ -193,8 +187,12 @@ export function getPathAdjustedSize(path: Fill[], targetInput: BigNumber = POSIT
|
|||||||
for (const fill of path) {
|
for (const fill of path) {
|
||||||
if (input.plus(fill.input).gte(targetInput)) {
|
if (input.plus(fill.input).gte(targetInput)) {
|
||||||
const di = targetInput.minus(input);
|
const di = targetInput.minus(input);
|
||||||
input = input.plus(di);
|
if (di.gt(0)) {
|
||||||
output = output.plus(fill.adjustedOutput.times(di.div(fill.input)));
|
input = input.plus(di);
|
||||||
|
// Penalty does not get interpolated.
|
||||||
|
const penalty = fill.adjustedOutput.minus(fill.output);
|
||||||
|
output = output.plus(fill.output.times(di.div(fill.input)).plus(penalty));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
input = input.plus(fill.input);
|
input = input.plus(fill.input);
|
||||||
@ -223,6 +221,10 @@ export function isValidPath(path: Fill[], skipDuplicateCheck: boolean = false):
|
|||||||
}
|
}
|
||||||
flags |= path[i].flags;
|
flags |= path[i].flags;
|
||||||
}
|
}
|
||||||
|
return arePathFlagsAllowed(flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arePathFlagsAllowed(flags: number): boolean {
|
||||||
const multiBridgeConflict = FillFlags.MultiBridge | FillFlags.ConflictsWithMultiBridge;
|
const multiBridgeConflict = FillFlags.MultiBridge | FillFlags.ConflictsWithMultiBridge;
|
||||||
return (flags & multiBridgeConflict) !== multiBridgeConflict;
|
return (flags & multiBridgeConflict) !== multiBridgeConflict;
|
||||||
}
|
}
|
||||||
@ -266,12 +268,14 @@ export function collapsePath(path: Fill[]): CollapsedFill[] {
|
|||||||
return collapsed;
|
return collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPathAdjustedCompleteRate(side: MarketOperation, path: Fill[], targetInput: BigNumber): BigNumber {
|
||||||
|
const [input, output] = getPathAdjustedSize(path, targetInput);
|
||||||
|
return getCompleteRate(side, input, output, targetInput);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPathAdjustedRate(side: MarketOperation, path: Fill[], targetInput: BigNumber): BigNumber {
|
export function getPathAdjustedRate(side: MarketOperation, path: Fill[], targetInput: BigNumber): BigNumber {
|
||||||
const [, output] = getPathAdjustedSize(path, targetInput);
|
const [input, output] = getPathAdjustedSize(path, targetInput);
|
||||||
if (output.eq(0)) {
|
return getRate(side, input, output);
|
||||||
return ZERO_AMOUNT;
|
|
||||||
}
|
|
||||||
return side === MarketOperation.Sell ? output.div(targetInput) : targetInput.div(output);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPathAdjustedSlippage(
|
export function getPathAdjustedSlippage(
|
||||||
@ -287,3 +291,29 @@ export function getPathAdjustedSlippage(
|
|||||||
const rateChange = maxRate.minus(totalRate);
|
const rateChange = maxRate.minus(totalRate);
|
||||||
return rateChange.div(maxRate).toNumber();
|
return rateChange.div(maxRate).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCompleteRate(
|
||||||
|
side: MarketOperation,
|
||||||
|
input: BigNumber,
|
||||||
|
output: BigNumber,
|
||||||
|
targetInput: BigNumber,
|
||||||
|
): BigNumber {
|
||||||
|
if (input.eq(0) || output.eq(0) || targetInput.eq(0)) {
|
||||||
|
return ZERO_AMOUNT;
|
||||||
|
}
|
||||||
|
// Penalize paths that fall short of the entire input amount by a factor of
|
||||||
|
// input / targetInput => (i / t)
|
||||||
|
if (side === MarketOperation.Sell) {
|
||||||
|
// (o / i) * (i / t) => (o / t)
|
||||||
|
return output.div(targetInput);
|
||||||
|
}
|
||||||
|
// (i / o) * (i / t)
|
||||||
|
return input.div(output).times(input.div(targetInput));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRate(side: MarketOperation, input: BigNumber, output: BigNumber): BigNumber {
|
||||||
|
if (input.eq(0) || output.eq(0)) {
|
||||||
|
return ZERO_AMOUNT;
|
||||||
|
}
|
||||||
|
return side === MarketOperation.Sell ? output.div(input) : input.div(output);
|
||||||
|
}
|
||||||
|
@ -3,10 +3,18 @@ import { BigNumber } from '@0x/utils';
|
|||||||
import { MarketOperation } from '../../types';
|
import { MarketOperation } from '../../types';
|
||||||
|
|
||||||
import { ZERO_AMOUNT } from './constants';
|
import { ZERO_AMOUNT } from './constants';
|
||||||
import { getPathAdjustedRate, getPathSize, isValidPath } from './fills';
|
import {
|
||||||
|
arePathFlagsAllowed,
|
||||||
|
getCompleteRate,
|
||||||
|
getPathAdjustedCompleteRate,
|
||||||
|
getPathAdjustedRate,
|
||||||
|
getPathAdjustedSize,
|
||||||
|
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 no-bitwise
|
||||||
|
|
||||||
const RUN_LIMIT_DECAY_FACTOR = 0.5;
|
const RUN_LIMIT_DECAY_FACTOR = 0.5;
|
||||||
|
|
||||||
@ -20,11 +28,13 @@ export async function findOptimalPathAsync(
|
|||||||
targetInput: BigNumber,
|
targetInput: BigNumber,
|
||||||
runLimit: number = 2 ** 8,
|
runLimit: number = 2 ** 8,
|
||||||
): Promise<Fill[] | undefined> {
|
): Promise<Fill[] | undefined> {
|
||||||
// Sort paths by descending adjusted rate.
|
// Sort paths by descending adjusted completed rate.
|
||||||
const sortedPaths = paths
|
const sortedPaths = paths
|
||||||
.slice(0)
|
.slice(0)
|
||||||
.sort((a, b) =>
|
.sort((a, b) =>
|
||||||
getPathAdjustedRate(side, b, targetInput).comparedTo(getPathAdjustedRate(side, a, targetInput)),
|
getPathAdjustedCompleteRate(side, b, targetInput).comparedTo(
|
||||||
|
getPathAdjustedCompleteRate(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()) {
|
||||||
@ -42,11 +52,12 @@ function mixPaths(
|
|||||||
targetInput: BigNumber,
|
targetInput: BigNumber,
|
||||||
maxSteps: number,
|
maxSteps: number,
|
||||||
): Fill[] {
|
): Fill[] {
|
||||||
const _maxSteps = Math.max(maxSteps, 16);
|
const _maxSteps = Math.max(maxSteps, 32);
|
||||||
let bestPath: Fill[] = pathA;
|
|
||||||
let bestPathInput = getPathSize(pathA, targetInput)[0];
|
|
||||||
let bestPathRate = getPathAdjustedRate(side, pathA, targetInput);
|
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
|
// We assume pathA is the better of the two initially.
|
||||||
|
let bestPath: Fill[] = pathA;
|
||||||
|
let [bestPathInput, bestPathOutput] = getPathAdjustedSize(pathA, targetInput);
|
||||||
|
let bestPathRate = getCompleteRate(side, bestPathInput, bestPathOutput, targetInput);
|
||||||
const _isBetterPath = (input: BigNumber, rate: BigNumber) => {
|
const _isBetterPath = (input: BigNumber, rate: BigNumber) => {
|
||||||
if (bestPathInput.lt(targetInput)) {
|
if (bestPathInput.lt(targetInput)) {
|
||||||
return input.gt(bestPathInput);
|
return input.gt(bestPathInput);
|
||||||
@ -55,64 +66,77 @@ function mixPaths(
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const _walk = (path: Fill[], input: BigNumber, output: BigNumber, allFills: Fill[]) => {
|
const _walk = (path: Fill[], input: BigNumber, output: BigNumber, flags: number, remainingFills: Fill[]) => {
|
||||||
steps += 1;
|
steps += 1;
|
||||||
const rate = getRate(side, targetInput, output);
|
const rate = getCompleteRate(side, input, output, targetInput);
|
||||||
if (_isBetterPath(input, rate)) {
|
if (_isBetterPath(input, rate)) {
|
||||||
bestPath = path;
|
bestPath = path;
|
||||||
bestPathInput = input;
|
bestPathInput = input;
|
||||||
|
bestPathOutput = output;
|
||||||
bestPathRate = rate;
|
bestPathRate = rate;
|
||||||
}
|
}
|
||||||
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 && steps < _maxSteps; ++i) {
|
for (let i = 0; i < remainingFills.length && steps < _maxSteps; ++i) {
|
||||||
const fill = allFills[i];
|
const fill = remainingFills[i];
|
||||||
const nextPath = [...path, fill];
|
|
||||||
// Only walk valid paths.
|
// Only walk valid paths.
|
||||||
if (!isValidPath(nextPath, true)) {
|
if (!isValidNextPathFill(path, flags, fill)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Remove this fill from the next list of candidate fills.
|
// Remove this fill from the next list of candidate fills.
|
||||||
const nextAllFills = allFills.slice();
|
const nextRemainingFills = remainingFills.slice();
|
||||||
nextAllFills.splice(i, 1);
|
nextRemainingFills.splice(i, 1);
|
||||||
// Recurse.
|
// Recurse.
|
||||||
_walk(
|
_walk(
|
||||||
nextPath,
|
[...path, fill],
|
||||||
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
|
||||||
// input.
|
// input.
|
||||||
clipFillAdjustedOutput(fill, remainingInput),
|
clipFillAdjustedOutput(fill, remainingInput),
|
||||||
),
|
),
|
||||||
nextAllFills,
|
flags | fill.flags,
|
||||||
|
nextRemainingFills,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const allPaths = [...pathA, ...pathB];
|
const allFills = [...pathA, ...pathB];
|
||||||
const sources = allPaths.filter(f => f.index === 0).map(f => f.sourcePathId);
|
const sources = allFills.filter(f => f.index === 0).map(f => f.sourcePathId);
|
||||||
const rateBySource = Object.assign(
|
const rateBySource = Object.assign(
|
||||||
{},
|
{},
|
||||||
...sources.map(s => ({
|
...sources.map(s => ({
|
||||||
[s]: getPathAdjustedRate(side, allPaths.filter(f => f.sourcePathId === s), targetInput),
|
[s]: getPathAdjustedRate(side, allFills.filter(f => f.sourcePathId === s), targetInput),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
_walk(
|
// Sort subpaths by rate and keep fills contiguous to improve our
|
||||||
[],
|
// chances of walking ideal, valid paths first.
|
||||||
ZERO_AMOUNT,
|
const sortedFills = allFills.sort((a, b) => {
|
||||||
ZERO_AMOUNT,
|
if (a.sourcePathId !== b.sourcePathId) {
|
||||||
// Sort subpaths by rate and keep fills contiguous to improve our
|
return rateBySource[b.sourcePathId].comparedTo(rateBySource[a.sourcePathId]);
|
||||||
// chances of walking ideal, valid paths first.
|
}
|
||||||
allPaths.sort((a, b) => {
|
return a.index - b.index;
|
||||||
if (a.sourcePathId !== b.sourcePathId) {
|
});
|
||||||
return rateBySource[b.sourcePathId].comparedTo(rateBySource[a.sourcePathId]);
|
_walk([], ZERO_AMOUNT, ZERO_AMOUNT, 0, sortedFills);
|
||||||
}
|
if (!isValidPath(bestPath)) {
|
||||||
return a.index - b.index;
|
throw new Error('nooope');
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
return bestPath;
|
return bestPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidNextPathFill(path: Fill[], pathFlags: number, fill: Fill): boolean {
|
||||||
|
if (path.length === 0) {
|
||||||
|
return !fill.parent;
|
||||||
|
}
|
||||||
|
if (path[path.length - 1] === fill.parent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (fill.parent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return arePathFlagsAllowed(pathFlags | fill.flags);
|
||||||
|
}
|
||||||
|
|
||||||
function isPathComplete(path: Fill[], targetInput: BigNumber): boolean {
|
function isPathComplete(path: Fill[], targetInput: BigNumber): boolean {
|
||||||
const [input] = getPathSize(path);
|
const [input] = getPathSize(path);
|
||||||
return input.gte(targetInput);
|
return input.gte(targetInput);
|
||||||
@ -122,16 +146,7 @@ function clipFillAdjustedOutput(fill: Fill, remainingInput: BigNumber): BigNumbe
|
|||||||
if (fill.input.lte(remainingInput)) {
|
if (fill.input.lte(remainingInput)) {
|
||||||
return fill.adjustedOutput;
|
return fill.adjustedOutput;
|
||||||
}
|
}
|
||||||
|
// Penalty does not get interpolated.
|
||||||
const penalty = fill.adjustedOutput.minus(fill.output);
|
const penalty = fill.adjustedOutput.minus(fill.output);
|
||||||
return remainingInput.times(fill.rate).plus(penalty);
|
return remainingInput.times(fill.output.div(fill.input)).plus(penalty);
|
||||||
}
|
|
||||||
|
|
||||||
function getRate(side: MarketOperation, input: BigNumber, output: BigNumber): BigNumber {
|
|
||||||
if (input.eq(0) || output.eq(0)) {
|
|
||||||
return ZERO_AMOUNT;
|
|
||||||
}
|
|
||||||
if (side === MarketOperation.Sell) {
|
|
||||||
return output.div(input);
|
|
||||||
}
|
|
||||||
return input.div(output);
|
|
||||||
}
|
}
|
||||||
|
@ -130,10 +130,6 @@ export interface Fill<TFillData extends FillData = FillData> {
|
|||||||
input: BigNumber;
|
input: BigNumber;
|
||||||
// Output fill amount (maker asset amount in a sell, taker asset amount in a buy).
|
// Output fill amount (maker asset amount in a sell, taker asset amount in a buy).
|
||||||
output: BigNumber;
|
output: BigNumber;
|
||||||
// The maker/taker rate.
|
|
||||||
rate: BigNumber;
|
|
||||||
// The maker/taker rate, adjusted by fees.
|
|
||||||
adjustedRate: BigNumber;
|
|
||||||
// The output fill amount, ajdusted by fees.
|
// The output fill amount, ajdusted by fees.
|
||||||
adjustedOutput: BigNumber;
|
adjustedOutput: BigNumber;
|
||||||
// Fill that must precede this one. This enforces certain fills to be contiguous.
|
// Fill that must precede this one. This enforces certain fills to be contiguous.
|
||||||
|
@ -813,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.Curve],
|
[ERC20BridgeSource.Uniswap],
|
||||||
[ERC20BridgeSource.Native],
|
[ERC20BridgeSource.Native],
|
||||||
[ERC20BridgeSource.Eth2Dai],
|
[ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Curve],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -1114,8 +1114,8 @@ describe('MarketOperationUtils tests', () => {
|
|||||||
it('batches contiguous bridge sources', async () => {
|
it('batches contiguous bridge sources', async () => {
|
||||||
const rates: RatesBySource = {};
|
const rates: RatesBySource = {};
|
||||||
rates[ERC20BridgeSource.Native] = [0.5, 0.01, 0.01, 0.01];
|
rates[ERC20BridgeSource.Native] = [0.5, 0.01, 0.01, 0.01];
|
||||||
rates[ERC20BridgeSource.Eth2Dai] = [0.49, 0.01, 0.01, 0.01];
|
rates[ERC20BridgeSource.Eth2Dai] = [0.49, 0.02, 0.01, 0.01];
|
||||||
rates[ERC20BridgeSource.Uniswap] = [0.48, 0.47, 0.01, 0.01];
|
rates[ERC20BridgeSource.Uniswap] = [0.48, 0.01, 0.01, 0.01];
|
||||||
replaceSamplerOps({
|
replaceSamplerOps({
|
||||||
getBuyQuotesAsync: createGetMultipleBuyQuotesOperationFromRates(rates),
|
getBuyQuotesAsync: createGetMultipleBuyQuotesOperationFromRates(rates),
|
||||||
});
|
});
|
||||||
@ -1133,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.Uniswap, ERC20BridgeSource.Eth2Dai],
|
[ERC20BridgeSource.Eth2Dai, ERC20BridgeSource.Uniswap],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user