Skip to content

Commit

Permalink
Best trade (Uniswap#20)
Browse files Browse the repository at this point in the history
* First test for the best trade router

* Share the array in the recursion

* Comment

* Add comparator methods and more tests

* Fix the equalTo method on the fraction

* add a todo, rename `n`

* Best trade exact out, more tests

* Faster sorted insert

* Comment fix

* Handle insufficient reserves and input amount errors

* Test improvements, export best trade options

* Improvements to fraction, make inputOutputAmount reusable

* Make comparator more reusable
  • Loading branch information
moodysalem committed May 6, 2020
1 parent 8c7fb08 commit 22453c0
Show file tree
Hide file tree
Showing 6 changed files with 566 additions and 3 deletions.
30 changes: 30 additions & 0 deletions src/entities/fractions/fraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export class Fraction {

add(other: Fraction | BigintIsh): Fraction {
const otherParsed = other instanceof Fraction ? other : new Fraction(parseBigintIsh(other))
if (JSBI.equal(this.denominator, otherParsed.denominator)) {
return new Fraction(JSBI.add(this.numerator, otherParsed.numerator), this.denominator)
}
return new Fraction(
JSBI.add(
JSBI.multiply(this.numerator, otherParsed.denominator),
Expand All @@ -54,6 +57,9 @@ export class Fraction {

subtract(other: Fraction | BigintIsh): Fraction {
const otherParsed = other instanceof Fraction ? other : new Fraction(parseBigintIsh(other))
if (JSBI.equal(this.denominator, otherParsed.denominator)) {
return new Fraction(JSBI.subtract(this.numerator, otherParsed.numerator), this.denominator)
}
return new Fraction(
JSBI.subtract(
JSBI.multiply(this.numerator, otherParsed.denominator),
Expand All @@ -63,6 +69,30 @@ export class Fraction {
)
}

lessThan(other: Fraction | BigintIsh): boolean {
const otherParsed = other instanceof Fraction ? other : new Fraction(parseBigintIsh(other))
return JSBI.lessThan(
JSBI.multiply(this.numerator, otherParsed.denominator),
JSBI.multiply(otherParsed.numerator, this.denominator)
)
}

equalTo(other: Fraction | BigintIsh): boolean {
const otherParsed = other instanceof Fraction ? other : new Fraction(parseBigintIsh(other))
return JSBI.equal(
JSBI.multiply(this.numerator, otherParsed.denominator),
JSBI.multiply(otherParsed.numerator, this.denominator)
)
}

greaterThan(other: Fraction | BigintIsh): boolean {
const otherParsed = other instanceof Fraction ? other : new Fraction(parseBigintIsh(other))
return JSBI.greaterThan(
JSBI.multiply(this.numerator, otherParsed.denominator),
JSBI.multiply(otherParsed.numerator, this.denominator)
)
}

multiply(other: Fraction | BigintIsh): Fraction {
const otherParsed = other instanceof Fraction ? other : new Fraction(parseBigintIsh(other))
return new Fraction(
Expand Down
183 changes: 180 additions & 3 deletions src/entities/trade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { Route } from './route'
import { TokenAmount } from './fractions'
import { Price } from './fractions/price'
import { Percent } from './fractions/percent'
import { Token } from 'entities/token'
import { sortedInsert } from '../utils'
import { InsufficientReservesError, InsufficientInputAmountError } from '../errors'

function getSlippage(midPrice: Price, inputAmount: TokenAmount, outputAmount: TokenAmount): Percent {
const exactQuote = midPrice.raw.multiply(inputAmount.raw)
Expand All @@ -14,6 +17,45 @@ function getSlippage(midPrice: Price, inputAmount: TokenAmount, outputAmount: To
return new Percent(slippage.numerator, slippage.denominator)
}

// minimal interface so the input output comparator may be shared across types
interface InputOutput {
readonly inputAmount: TokenAmount
readonly outputAmount: TokenAmount
}

// comparator function that allows sorting trades by their output amounts, in decreasing order, and then input amounts
// in increasing order. i.e. the best trades have the most outputs for the least inputs and are sorted first
export function inputOutputComparator(tradeA: InputOutput, tradeB: InputOutput): number {
// must have same input and output token for comparison
invariant(tradeA.inputAmount.token.equals(tradeB.inputAmount.token), 'INPUT_TOKEN')
invariant(tradeA.outputAmount.token.equals(tradeB.outputAmount.token), 'OUTPUT_TOKEN')
if (tradeA.outputAmount.equalTo(tradeB.outputAmount)) {
if (tradeA.inputAmount.equalTo(tradeB.inputAmount)) {
return 0
}
// trade A requires less input than trade B, so A should come first
if (tradeA.inputAmount.lessThan(tradeB.inputAmount)) {
return -1
} else {
return 1
}
} else {
// tradeA has less output than trade B, so should come second
if (tradeA.outputAmount.lessThan(tradeB.outputAmount)) {
return 1
} else {
return -1
}
}
}

export interface BestTradeOptions {
// how many results to return
maxNumResults?: number
// the maximum number of hops a trade should contain
maxHops?: number
}

export class Trade {
public readonly route: Route
public readonly tradeType: TradeType
Expand All @@ -23,7 +65,7 @@ export class Trade {
public readonly nextMidPrice: Price
public readonly slippage: Percent

constructor(route: Route, amount: TokenAmount, tradeType: TradeType) {
public constructor(route: Route, amount: TokenAmount, tradeType: TradeType) {
invariant(amount.token.equals(tradeType === TradeType.EXACT_INPUT ? route.input : route.output), 'TOKEN')
const amounts: TokenAmount[] = new Array(route.path.length)
const nextPairs: Pair[] = new Array(route.pairs.length)
Expand Down Expand Up @@ -52,8 +94,143 @@ export class Trade {
this.inputAmount = inputAmount
this.outputAmount = outputAmount
this.executionPrice = new Price(route.input, route.output, inputAmount.raw, outputAmount.raw)
const nextMidPrice = Price.fromRoute(new Route(nextPairs, route.input))
this.nextMidPrice = nextMidPrice
this.nextMidPrice = Price.fromRoute(new Route(nextPairs, route.input))
this.slippage = getSlippage(route.midPrice, inputAmount, outputAmount)
}

// given a list of pairs, and a fixed amount in, returns the top `maxNumResults` trades that go from an input token
// amount to an output token, making at most `maxHops` hops
// note this does not consider aggregation, as routes are linear. it's possible a better route exists by splitting
// the amount in among multiple routes.
public static bestTradeExactIn(
pairs: Pair[],
amountIn: TokenAmount,
tokenOut: Token,
{ maxNumResults = 3, maxHops = 3 }: BestTradeOptions = {},
// used in recursion.
currentPairs: Pair[] = [],
originalAmountIn: TokenAmount = amountIn,
bestTrades: Trade[] = []
): Trade[] {
if (pairs.length === 0) {
return bestTrades
}

invariant(maxHops > 0, 'MAX_HOPS')
invariant(originalAmountIn === amountIn || currentPairs.length > 0, 'INVALID_RECURSION')

for (let i = 0; i < pairs.length; i++) {
const pair = pairs[i]
// pair irrelevant
if (!pair.token0.equals(amountIn.token) && !pair.token1.equals(amountIn.token)) continue

let amountOut: TokenAmount
try {
;[amountOut] = pair.getOutputAmount(amountIn)
} catch (error) {
if (error instanceof InsufficientInputAmountError) {
continue
}
throw error
}
// we have arrived at the output token, so this is the final trade of one of the paths
if (amountOut!.token.equals(tokenOut)) {
sortedInsert(
bestTrades,
new Trade(
new Route([...currentPairs, pair], originalAmountIn.token),
originalAmountIn,
TradeType.EXACT_INPUT
),
maxNumResults,
inputOutputComparator
)
} else if (maxHops > 1) {
const pairsExcludingThisPair = pairs.slice(0, i).concat(pairs.slice(i + 1, pairs.length))

// otherwise, consider all the other paths that lead from this token as long as we have not exceeded maxHops
Trade.bestTradeExactIn(
pairsExcludingThisPair,
amountOut!,
tokenOut,
{
maxNumResults,
maxHops: maxHops - 1
},
[...currentPairs, pair],
originalAmountIn,
bestTrades
)
}
}

return bestTrades
}

// similar to the above method but instead targets a fixed output amount
// given a list of pairs, and a fixed amount out, returns the top `maxNumResults` trades that go from an input token
// to an output token amount, making at most `maxHops` hops
// note this does not consider aggregation, as routes are linear. it's possible a better route exists by splitting
// the amount in among multiple routes.
public static bestTradeExactOut(
pairs: Pair[],
tokenIn: Token,
amountOut: TokenAmount,
{ maxNumResults = 3, maxHops = 3 }: BestTradeOptions = {},
// used in recursion.
currentPairs: Pair[] = [],
originalAmountOut: TokenAmount = amountOut,
bestTrades: Trade[] = []
): Trade[] {
if (pairs.length === 0) {
return bestTrades
}

invariant(maxHops > 0, 'MAX_HOPS')
invariant(originalAmountOut === amountOut || currentPairs.length > 0, 'INVALID_RECURSION')

for (let i = 0; i < pairs.length; i++) {
const pair = pairs[i]
// pair irrelevant
if (!pair.token0.equals(amountOut.token) && !pair.token1.equals(amountOut.token)) continue

let amountIn: TokenAmount
try {
;[amountIn] = pair.getInputAmount(amountOut)
} catch (error) {
// not enough liquidity in this pair
if (error instanceof InsufficientReservesError) {
continue
}
throw error
}
// we have arrived at the input token, so this is the first trade of one of the paths
if (amountIn!.token.equals(tokenIn)) {
sortedInsert(
bestTrades,
new Trade(new Route([pair, ...currentPairs], tokenIn), originalAmountOut, TradeType.EXACT_OUTPUT),
maxNumResults,
inputOutputComparator
)
} else if (maxHops > 1) {
const pairsExcludingThisPair = pairs.slice(0, i).concat(pairs.slice(i + 1, pairs.length))

// otherwise, consider all the other paths that arrive at this token as long as we have not exceeded maxHops
Trade.bestTradeExactOut(
pairsExcludingThisPair,
tokenIn,
amountIn!,
{
maxNumResults,
maxHops: maxHops - 1
},
[pair, ...currentPairs],
originalAmountOut,
bestTrades
)
}
}

return bestTrades
}
}
34 changes: 34 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,37 @@ export function sqrt(y: JSBI): JSBI {
}
return z
}

// given an array of items sorted by `comparator`, insert an item into its sort index and constrain the size to
// `maxSize` by removing the last item
export function sortedInsert<T>(items: T[], add: T, maxSize: number, comparator: (a: T, b: T) => number): T | null {
invariant(maxSize > 0, 'MAX_SIZE_ZERO')
// this is an invariant because the interface cannot return multiple removed items if items.length exceeds maxSize
invariant(items.length <= maxSize, 'ITEMS_SIZE')

// short circuit first item add
if (items.length === 0) {
items.push(add)
return null
} else {
const isFull = items.length === maxSize
// short circuit if full and the additional item does not come before the last item
if (isFull && comparator(items[items.length - 1], add) <= 0) {
return add
}

let lo = 0,
hi = items.length

while (lo < hi) {
const mid = (lo + hi) >>> 1
if (comparator(items[mid], add) <= 0) {
lo = mid + 1
} else {
hi = mid
}
}
items.splice(lo, 0, add)
return isFull ? items.pop()! : null
}
}
Loading

0 comments on commit 22453c0

Please sign in to comment.