Implement RouterUtils.getPriceImpactForRoute (#108)

- Add a function in RouterUtils to allow calculation of price impact
- Supports multi-split & multi-hop routes
- bump to v0.11.1
This commit is contained in:
meep 2023-07-05 20:36:36 +08:00 committed by GitHub
parent 42ca95a4bc
commit 7f05704f39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 376 additions and 6 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@orca-so/whirlpools-sdk",
"version": "0.11.0",
"version": "0.11.1",
"description": "Typescript SDK to interact with Orca's Whirlpool program.",
"license": "Apache-2.0",
"main": "dist/index.js",
@ -64,4 +64,4 @@
"url": "https://github.com/orca-so/whirlpools/issues"
},
"homepage": "https://www.orca.so"
}
}

View File

@ -97,7 +97,9 @@ export async function getSwapFromRoute(
]
);
const inputAmount = quoteOne.amountSpecifiedIsInput ? quoteOne.estimatedAmountIn : quoteOne.otherAmountThreshold;
const inputAmount = quoteOne.amountSpecifiedIsInput
? quoteOne.estimatedAmountIn
: quoteOne.otherAmountThreshold;
addOrNative(mintOneA.toString(), quoteOne.aToB ? inputAmount : ZERO);
addOrNative(mintOneB.toString(), !quoteOne.aToB ? inputAmount : ZERO);
addOrNative(mintTwoA.toString(), ZERO);

View File

@ -93,7 +93,12 @@ export class PriceModule {
AddressUtil.toStrings(filteredPoolAddresses)
);
const tickArrayMap = await PriceModuleUtils.fetchTickArraysForPools(fetcher, poolMap, config, opts);
const tickArrayMap = await PriceModuleUtils.fetchTickArraysForPools(
fetcher,
poolMap,
config,
opts
);
const mints = Array.from(
Object.values(poolMap).reduce((acc, pool) => {
acc.add(pool.tokenMintA.toBase58());

View File

@ -90,6 +90,7 @@ export type SubTradeRoute = {
* @param vaultA The address of the first vault in the pool.
* @param vaultB The address of the second vault in the pool.
* @param quote The {@link SwapQuote} for this hop.
* @param snapshot A snapshot of the whirlpool condition when this hop was made
*/
export type TradeHop = {
amountIn: BN;
@ -102,6 +103,17 @@ export type TradeHop = {
vaultA: Address;
vaultB: Address;
quote: SwapQuote;
snapshot: TradeHopSnapshot;
};
/**
* A snapshot of the whirlpool condition when a trade hop was made.
* @category Router
*/
export type TradeHopSnapshot = {
aToB: boolean;
sqrtPrice: BN;
feeRate: Percentage;
};
/**

View File

@ -8,10 +8,14 @@ import {
} from "@orca-so/common-sdk";
import { Account } from "@solana/spl-token";
import { PublicKey } from "@solana/web3.js";
import { ExecutableRoute, RoutingOptions, TradeRoute } from ".";
import BN from "bn.js";
import Decimal from "decimal.js";
import { ExecutableRoute, RoutingOptions, Trade, TradeRoute } from ".";
import { WhirlpoolContext } from "../../context";
import { getSwapFromRoute } from "../../instructions/composites/swap-with-route";
import { PREFER_CACHE } from "../../network/public/fetcher";
import { U64 } from "../../utils/math/constants";
import { PriceMath } from "../../utils/public";
import { isWalletConnected } from "../../utils/wallet-utils";
/**
@ -135,6 +139,60 @@ export class RouterUtils {
return null;
}
/**
* Calculate the price impact for a route.
* @param trade The trade the user used to derive the route.
* @param route The route to calculate the price impact for.
* @returns A Decimal object representing the percentage value of the price impact (ex. 3.01%)
*/
static getPriceImpactForRoute(trade: Trade, route: TradeRoute): Decimal {
const { amountSpecifiedIsInput } = trade;
const totalBaseValue = route.subRoutes.reduce((acc, route) => {
const directionalHops = amountSpecifiedIsInput
? route.hopQuotes
: route.hopQuotes.slice().reverse();
const baseOutputs = directionalHops.reduce((acc, quote, index) => {
const { snapshot } = quote;
const { aToB, sqrtPrice, feeRate } = snapshot;
// Inverse sqrt price will cause 1bps precision loss since ticks are spaces of 1bps
const directionalSqrtPrice = aToB ? sqrtPrice : PriceMath.invertSqrtPriceX64(sqrtPrice);
// Convert from in/out -> base_out/in using the directional price & fee rate
let nextBaseValue;
const price = directionalSqrtPrice.mul(directionalSqrtPrice).div(U64);
if (amountSpecifiedIsInput) {
const amountIn = index === 0 ? quote.amountIn : acc[index - 1];
const feeAdjustedAmount = amountIn
.mul(feeRate.denominator.sub(feeRate.numerator))
.div(feeRate.denominator);
nextBaseValue = price.mul(feeAdjustedAmount).div(U64);
} else {
const amountOut = index === 0 ? quote.amountOut : acc[index - 1];
const feeAdjustedAmount = amountOut.mul(U64).div(price);
nextBaseValue = feeAdjustedAmount
.mul(feeRate.denominator)
.div(feeRate.denominator.sub(feeRate.numerator));
}
acc.push(nextBaseValue);
return acc;
}, new Array<BN>());
return acc.add(baseOutputs[baseOutputs.length - 1]);
}, new BN(0));
const totalBaseValueDec = new Decimal(totalBaseValue.toString());
const totalAmountEstimatedDec = new Decimal(
amountSpecifiedIsInput ? route.totalAmountOut.toString() : route.totalAmountIn.toString()
);
const priceImpact = amountSpecifiedIsInput
? totalBaseValueDec.sub(totalAmountEstimatedDec).div(totalBaseValueDec)
: totalAmountEstimatedDec.sub(totalBaseValueDec).div(totalAmountEstimatedDec);
return priceImpact.mul(100);
}
/**
* Get the tick arrays addresses that are touched by a route.
* @param route The route to get the tick arrays from.

View File

@ -5,7 +5,7 @@ import BN from "bn.js";
import { SwapErrorCode } from "../errors/errors";
import { PREFER_CACHE, WhirlpoolAccountFetcherInterface } from "../network/public/fetcher";
import { SwapQuoteParam, swapQuoteWithParams } from "../quotes/public";
import { Path } from "../utils/public";
import { Path, PoolUtil } from "../utils/public";
import { SwapQuoteRequest, batchBuildSwapQuoteParams } from "./batch-swap-quote";
import { RoutingOptions, Trade, TradeHop } from "./public";
@ -124,6 +124,11 @@ function populateQuoteMap(
vaultA,
vaultB,
quote,
snapshot: {
aToB: swapParam.aToB,
sqrtPrice: whirlpoolData.sqrtPrice,
feeRate: PoolUtil.getFeeRate(whirlpoolData.feeRate),
},
};
} catch (e: any) {
const errorCode: SwapErrorCode = e.errorCode;

View File

@ -0,0 +1,3 @@
import { ONE, U64_MAX } from "@orca-so/common-sdk";
export const U64 = U64_MAX.add(ONE);

View File

@ -126,6 +126,8 @@ export class PriceMath {
/**
* Utility to invert the price Pb/Pa to Pa/Pb
* NOTE: precision is lost in this conversion
*
* @param price Pb / Pa
* @param decimalsA Decimals of original token A (i.e. token A in the given Pb / Pa price)
* @param decimalsB Decimals of original token B (i.e. token B in the given Pb / Pa price)
@ -139,6 +141,8 @@ export class PriceMath {
/**
* Utility to invert the sqrtPriceX64 from X64 repr. of sqrt(Pb/Pa) to X64 repr. of sqrt(Pa/Pb)
* NOTE: precision is lost in this conversion
*
* @param sqrtPriceX64 X64 representation of sqrt(Pb / Pa)
* @returns inverted sqrtPriceX64, i.e. X64 representation of sqrt(Pa / Pb)
*/

View File

@ -0,0 +1,281 @@
import { Percentage } from "@orca-so/common-sdk";
import { PublicKey } from "@solana/web3.js";
import * as assert from "assert";
import BN from "bn.js";
import Decimal from "decimal.js";
import { PriceMath, RouterUtils } from "../../../src";
import { U64 } from "../../../src/utils/math/constants";
const maxDecimalAccuracy = 4;
describe("RouterUtil - Price Impact tests", () => {
// Mock a Orca -> USDC ExactIn trade that has no split route and goes through a single hop (ORCA -> USDC)
it("ExactIn, a->b true, single-hop, 1 split", () => {
const params: RouteTestParam = {
amountSpecifiedIsInput: true,
totalAmountIn: new BN("1000000"),
totalAmountOut: new BN("581050"),
subRouteParams: [
{
hops: [
{
aToB: true,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("14082503933855903449"),
amountIn: new BN("1000000"),
amountOut: new BN("581050"),
},
],
},
],
};
const { trade, routes } = buildRouteTest(params);
const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces(
maxDecimalAccuracy
);
const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy);
assert.equal(impact.toString(), expect.toString());
});
// Mock a Orca -> USDC ExactOut trade that has no split route and goes through a single hop (ORCA -> USDC)
it("ExactOut, a->b false, single-hop, 1 split", () => {
const params: RouteTestParam = {
amountSpecifiedIsInput: false,
totalAmountIn: new BN("5833496"),
totalAmountOut: new BN("10000000"),
subRouteParams: [
{
hops: [
{
aToB: false,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("14067691597581169278"),
amountIn: new BN("5833496"),
amountOut: new BN("10000000"),
},
],
},
],
};
const { trade, routes } = buildRouteTest(params);
const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces(
maxDecimalAccuracy
);
const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy);
assert.equal(impact.toString(), expect.toString());
});
// Mock a ORCA -> USDC trade that has 2 split route and goes through a multi-hop (ORCA -> SOL -> USDC)
it("ExactIn, mix a->b, single & multi-hop, 2 splits", () => {
const params: RouteTestParam = {
amountSpecifiedIsInput: true,
totalAmountIn: new BN("40000000000"),
totalAmountOut: new BN("22277933969"),
subRouteParams: [
{
hops: [
{
aToB: false,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("3363616053614750676"),
amountIn: new BN("32000000000"),
amountOut: new BN("925083736236"),
},
{
aToB: true,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("2567715337494939945"),
amountIn: new BN("925083736236"),
amountOut: new BN("17871834810"),
},
],
},
{
hops: [
{
aToB: true,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("14082503933855903449"),
amountIn: new BN("8000000000"),
amountOut: new BN("4406099159"),
},
],
},
],
};
const { trade, routes } = buildRouteTest(params);
const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces(
maxDecimalAccuracy
);
const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy);
assert.equal(impact.toString(), expect.toString());
});
// Mock an ExactOut ORCA -> USDC trade that has 2 split route and goes through a multi-hop (ORCA -> SOL -> USDC)
it("ExactOut, mix a->b, single & multi-hop, 2 splits", () => {
const params: RouteTestParam = {
amountSpecifiedIsInput: false,
totalAmountIn: new BN("64800628033"),
totalAmountOut: new BN("34000000000"),
subRouteParams: [
{
hops: [
{
aToB: true,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("14067691597581169278"),
amountIn: new BN("13107594181"),
amountOut: new BN("6800000000"),
},
],
},
{
hops: [
{
aToB: false,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("3366318822902200326"),
amountIn: new BN("51693033852"),
amountOut: new BN("1403541983350"),
},
{
aToB: true,
feeRate: Percentage.fromFraction(3000, 1000000),
sqrtPrice: new BN("2572953144905521240"),
amountIn: new BN("1403541983350"),
amountOut: new BN("27200000000"),
},
],
},
],
};
const { trade, routes } = buildRouteTest(params);
const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces(
maxDecimalAccuracy
);
const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy);
assert.equal(impact.toString(), expect.toString());
});
// NOTE: The precision kept in these calculation slightly differs from the U64 calculation that we get from the RouterUtil function.
function calculateImpact(params: RouteTestParam): Decimal {
const { amountSpecifiedIsInput, totalAmountIn, totalAmountOut } = params;
const finalBaseValue = params.subRouteParams
.map((subRoute) => {
const { hops } = subRoute;
const directionalHops = amountSpecifiedIsInput ? hops : hops.slice().reverse();
const hopResults: Decimal[] = new Array(hops.length);
directionalHops.forEach((hop, index) => {
const { aToB, feeRate, sqrtPrice, amountIn, amountOut } = hop;
const directionalSqrtPrice = aToB
? new Decimal(sqrtPrice.toString())
: new Decimal(PriceMath.invertSqrtPriceX64(sqrtPrice).toString());
const directionalPrice = directionalSqrtPrice
.pow(2)
.div(U64.toString())
.div(U64.toString());
if (amountSpecifiedIsInput) {
const amountInDec =
index === 0 ? new Decimal(amountIn.toString()) : hopResults[index - 1];
const amountOutDec = amountInDec
.times(new Decimal(1).sub(feeRate.toDecimal()))
.times(directionalPrice);
hopResults[index] = amountOutDec.round();
} else {
const amountOutDec =
index === 0 ? new Decimal(amountOut.toString()) : hopResults[index - 1];
const amountInDec = amountOutDec
.div(new Decimal(1).sub(feeRate.toDecimal()))
.div(directionalPrice);
hopResults[index] = amountInDec.round();
}
});
return hopResults[hops.length - 1];
})
.reduce((acc, cur) => acc.add(cur), new Decimal(0));
if (amountSpecifiedIsInput) {
const totalAmountOutDec = new Decimal(totalAmountOut.toString());
return finalBaseValue.sub(totalAmountOutDec).div(finalBaseValue).mul(100);
} else {
const totalAmountInDec = new Decimal(totalAmountIn.toString());
return totalAmountInDec.sub(finalBaseValue).div(totalAmountInDec).mul(100);
}
}
type TradeHopTestParam = {
aToB: boolean;
feeRate: Percentage;
sqrtPrice: BN;
amountIn: BN;
amountOut: BN;
};
type SubRouteTestParam = {
hops: TradeHopTestParam[];
};
type RouteTestParam = {
amountSpecifiedIsInput: boolean;
subRouteParams: SubRouteTestParam[];
totalAmountIn: BN;
totalAmountOut: BN;
};
function buildRouteTest(params: RouteTestParam) {
return {
trade: {
tokenIn: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE",
tokenOut: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
tradeAmount: new BN(0),
amountSpecifiedIsInput: params.amountSpecifiedIsInput,
},
routes: {
subRoutes: params.subRouteParams.map((subRouteParam) => {
return {
hopQuotes: subRouteParam.hops.map((hopParam) => {
return {
amountIn: hopParam.amountIn,
amountOut: hopParam.amountOut,
whirlpool: PublicKey.default,
inputMint: PublicKey.default,
outputMint: PublicKey.default,
mintA: PublicKey.default,
mintB: PublicKey.default,
vaultA: PublicKey.default,
vaultB: PublicKey.default,
quote: {
amount: new BN(0),
otherAmountThreshold: new BN(0),
sqrtPriceLimit: new BN(0),
amountSpecifiedIsInput: params.amountSpecifiedIsInput,
aToB: hopParam.aToB,
tickArray0: PublicKey.default,
tickArray1: PublicKey.default,
tickArray2: PublicKey.default,
estimatedAmountIn: new BN(0),
estimatedAmountOut: new BN(0),
estimatedEndTickIndex: 0,
estimatedEndSqrtPrice: new BN(0),
estimatedFeeAmount: new BN(0),
},
snapshot: {
aToB: hopParam.aToB,
feeRate: hopParam.feeRate,
sqrtPrice: hopParam.sqrtPrice,
},
};
}),
path: {
startTokenMint: "startTokenMint",
endTokenMint: "endTokenMint",
edges: [],
},
splitPercent: 30,
amountIn: new BN(0),
amountOut: new BN(0),
};
}),
totalAmountIn: params.totalAmountIn,
totalAmountOut: params.totalAmountOut,
},
};
}
});