├── .gitignore ├── README.md ├── backtest.mjs ├── index.js ├── numbers.mjs ├── package-lock.json ├── package.json ├── uniPoolData.mjs └── uniPools.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # temp while developing perpetual 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uniswap V3 LP Strategy BackTester 2 | 3 | Strategy Backtester for providing liquidity to a Uniswap V3 Pool. Based on logic described in the following article: 4 | 5 | 6 | [Historical Performances of Uniswap V3 Pools](https://defi-lab.medium.com/historical-performances-of-uniswap-l3-pools-2de713f7c70f) 7 | 8 | ![backtest-performance](https://user-images.githubusercontent.com/5744432/167617903-efd0829f-0b32-4c7f-b611-47398d8e435c.png) 9 | 10 | 11 | ## Install 12 | 13 | ```shell 14 | npm install uniswap-v3-backtest 15 | ``` 16 | 17 | 18 | ## Usage 19 | 20 | ```js 21 | // get results for last 25 days 22 | import uniswapStrategyBacktest from 'uniswap-v3-backtest' 23 | const backtestResults = await uniswapStrategyBacktest("0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", 1000, 2120.09, 2662.99, {days: 25, period: "daily"}); 24 | 25 | // get results from start timestamp for lp from quote token 26 | await uniswapStrategyBacktest( 27 | "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", 28 | 1, 29 | 1/2662.99, 30 | 1/2120.09, 31 | {startTimestamp: 1653364800, period: "daily", priceToken: 1} 32 | ); 33 | 34 | // get results from start timestamp to end timestamp for lp from quote token 35 | await uniswapStrategyBacktest( 36 | "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", 37 | 1, 38 | 1/2662.99, 39 | 1/2120.09, 40 | {startTimestamp: 1653364800, endTimestamp: 1653374800, period: "daily", priceToken: 1} 41 | ); 42 | 43 | // get results for n days before end timestamp for lp from quote token 44 | await uniswapStrategyBacktest( 45 | "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", 46 | 1, 47 | 1/2662.99, 48 | 1/2120.09, 49 | {endTimestamp: 1653364800, days: 1, period: "daily", priceToken: 1} 50 | ); 51 | ``` 52 | 53 | Example Output: 54 | 55 | ``` 56 | 57 | // Hourly // 58 | 59 | { 60 | periodStartUnix: 1652274000, 61 | liquidity: '7675942584871332685', 62 | high: '2266.726774269547858798641816695647', 63 | low: '2145.393138561593202680715136665708', 64 | pool: { 65 | id: '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640', 66 | totalValueLockedUSD: '231193174.2181918276487229629805612', 67 | totalValueLockedToken1: '71499.990198836160751569', 68 | totalValueLockedToken0: '71417122.541685', 69 | token0: { decimals: '6' }, 70 | token1: { decimals: '18' } 71 | }, 72 | close: '2246.577649943233620476969923660446', 73 | feeGrowthGlobal0X128: '1432478142734251891146870279471391', 74 | feeGrowthGlobal1X128: '491787243029421936881695073823469456119843', 75 | day: 11, 76 | month: 4, 77 | year: 2022, 78 | fg0: 0, 79 | fg1: 0, 80 | activeliquidity: 100, 81 | feeToken0: 0, 82 | feeToken1: 0, 83 | tokens: [ 259.4720394308191, 0.3296249121804859 ], 84 | fgV: 0, 85 | feeV: 0, 86 | feeUnb: 0, 87 | amountV: 999.9999999999999, 88 | amountTR: 1000, 89 | feeUSD: 0, 90 | baseClose: '2246.577649943233620476969923660446' 91 | } 92 | 93 | // Daily // 94 | { 95 | date: '5/11/2022', 96 | day: 11, 97 | month: 4, 98 | year: 2022, 99 | feeToken0: -3.4440601554207775, 100 | feeToken1: -0.0015405996636897285, 101 | feeV: -7.061140100789986, 102 | feeUnb: -0.1933135552371638, 103 | fgV: -3.677055720111889e-14, 104 | feeUSD: -5.640380083495151, 105 | activeliquidity: 100, 106 | amountV: 999.9999999999998, 107 | amountTR: 1000, 108 | amountVLast: 1032.5671065225727, 109 | percFee: -0.7061140100789988, 110 | close: '2241.121068655049392145921171725936', 111 | baseClose: '2241.121068655049392145921171725936', 112 | count: 14 113 | } 114 | ``` 115 | 116 | ## **uniswapStrategyBacktest() input** 117 | 118 | uniswapStrategyBacktest() should be called with the following arguments: 119 | 120 | ``` 121 | uniswapStrategyBacktest( 122 | poolID, 123 | investmentAmount, 124 | minRange, 125 | maxRange, 126 | options 127 | ) 128 | ``` 129 | 130 | **poolID** = the ID of the pool you'd like to run the backtest for. Example for [ETH / USD 0.05%](https://info.uniswap.org/#/pools/0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640) would be "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640" 131 | 132 | **investmentAmount** = the initial amount invested in the LP strategy. This value is presumed to be denominated in the base token of the pair (Token0) but can be overridden to use the quote token with the options argument. 133 | 134 | **minRange** = the lower bound of the LP Strategy. As with investmentAmount, presumed to be in base but can be overridden to use quote. 135 | **maxRange** = the upper bound of the LP Strategy. As with investmentAmount, presumed to be in base but can be overridden to use quote. 136 | 137 | **options** = Optional values that override default values. Formed as a JSON key value pair `{days: 30, protocol: 0, priceToken: 0, period: "hourly"}` 138 | **days** = number of days to run the backtest from todays date. Defaults to 30, Currently maxed to 30. 139 | **startTimestamp** = timestamp in seconds for LP start. Optional. 140 | **endTimestamp** = timestamp in seconds for LP end. Optional. If used with *days* provides results for `n` days before timestamp 141 | **priceToken** = 0 = values in baseToken, 1 = values in quoteToken (Token0, Token1) 142 | **period** = Calculate fees "daily" or "hourly", defaults to "hourly" 143 | **protocol** - Which chain, sidechain or L2 to use: 144 | 0 = Ethereum (default) 145 | 1 = Optimism 146 | 2 = Arbitrum 147 | 3 = Polygon 148 | 149 | 150 | ## **uniswapStrategyBacktest() output** 151 | 152 | **amountV** = the total value of the LP position for the specified period. 153 | **feeV** = the fees generated for the specified period. 154 | **activeliquidity** = the % of the strategies liquidity that was active within the specified period. 155 | **feeUSD** = the total fees in USD 156 | 157 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /backtest.mjs: -------------------------------------------------------------------------------- 1 | import { logWithBase, round, sumArray, parsePrice } from "./numbers.mjs"; 2 | 3 | // calculate the amount of fees earned in 1 period by 1 unit of unbounded liquidity // 4 | // fg0 represents the amount of token 0, fg1 represents the amount of token1 // 5 | export const calcUnboundedFees = (globalfee0, prevGlobalfee0, globalfee1, prevGlobalfee1, poolSelected) => { 6 | 7 | const fg0_0 = ((parseInt(globalfee0)) / Math.pow(2, 128)) / (Math.pow(10, poolSelected.token0.decimals)); 8 | const fg0_1 = (((parseInt(prevGlobalfee0))) / Math.pow(2, 128)) / (Math.pow(10, poolSelected.token0.decimals)); 9 | 10 | const fg1_0 = ((parseInt(globalfee1)) / Math.pow(2, 128)) / (Math.pow(10, poolSelected.token1.decimals)); 11 | const fg1_1 = (((parseInt(prevGlobalfee1))) / Math.pow(2, 128)) / (Math.pow(10, poolSelected.token1.decimals)); 12 | 13 | const fg0 = (fg0_0 - fg0_1); // fee of token 0 earned in 1 period by 1 unit of unbounded liquidity 14 | const fg1 = (fg1_0 - fg1_1); // fee of token 1 earned in 1 period by 1 unit of unbounded liquidity 15 | 16 | return [fg0, fg1]; 17 | } 18 | 19 | // calculate the liquidity tick at a specified price 20 | export const getTickFromPrice = (price, pool, baseSelected = 0) => { 21 | const decimal0 = baseSelected && baseSelected === 1 ? parseInt(pool.token1.decimals) : parseInt(pool.token0.decimals); 22 | const decimal1 = baseSelected && baseSelected === 1 ? parseInt(pool.token0.decimals) : parseInt(pool.token1.decimals); 23 | const valToLog = parseFloat(price) * Math.pow(10, (decimal0 - decimal1)); 24 | const tickIDXRaw = logWithBase(valToLog, 1.0001); 25 | 26 | return round(tickIDXRaw, 0); 27 | } 28 | 29 | // estimate the percentage of active liquidity for 1 period for a strategy based on min max bounds 30 | // low and high are the period's candle low / high values 31 | export const activeLiquidityForCandle = (min, max, low, high) => { 32 | 33 | const divider = (high - low) !== 0 ? (high - low) : 1; 34 | const ratioTrue = (high - low) !== 0 ? (Math.min(max, high) - Math.max(min, low)) / divider : 1; 35 | let ratio = high > min && low < max ? ratioTrue * 100 : 0; 36 | 37 | return isNaN(ratio) || !ratio ? 0 : ratio; 38 | 39 | } 40 | 41 | // Calculate the number of tokens for a Strategy at a specific amount of liquidity & price 42 | export const tokensFromLiquidity = (price, low, high, liquidity, decimal0, decimal1) => { 43 | 44 | const decimal = decimal1 - decimal0; 45 | const lowHigh = [(Math.sqrt(low * Math.pow(10, decimal))) * Math.pow(2, 96), (Math.sqrt(high * Math.pow(10, decimal))) * Math.pow(2, 96)]; 46 | 47 | const sPrice = (Math.sqrt(price * Math.pow(10, decimal))) * Math.pow(2, 96); 48 | const sLow = Math.min(...lowHigh); 49 | const sHigh = Math.max(...lowHigh); 50 | 51 | if (sPrice <= sLow) { 52 | 53 | const amount1 = ((liquidity * Math.pow(2, 96) * (sHigh - sLow) / sHigh / sLow ) / Math.pow(10, decimal0) ); 54 | return [0, amount1]; 55 | 56 | } else if (sPrice < sHigh && sPrice > sLow) { 57 | const amount0 = liquidity * (sPrice - sLow) / Math.pow(2, 96) / Math.pow(10, decimal1); 58 | const amount1 = ((liquidity * Math.pow(2, 96) * (sHigh - sPrice) / sHigh / sPrice ) / Math.pow(10, decimal0) ); 59 | return [amount0, amount1]; 60 | } 61 | else { 62 | const amount0 = liquidity * (sHigh - sLow) / Math.pow(2, 96) / Math.pow(10, decimal1); 63 | return [amount0, 0]; 64 | } 65 | 66 | } 67 | 68 | // Calculate the number of Tokens a strategy owns at a specific price 69 | export const tokensForStrategy = (minRange, maxRange, investment, price, decimal) => { 70 | 71 | const sqrtPrice = Math.sqrt(price * (Math.pow(10, decimal))); 72 | const sqrtLow = Math.sqrt(minRange * (Math.pow(10, decimal))); 73 | const sqrtHigh = Math.sqrt(maxRange * (Math.pow(10, decimal))); 74 | 75 | let delta, amount0, amount1; 76 | 77 | if ( sqrtPrice > sqrtLow && sqrtPrice < sqrtHigh) { 78 | delta = investment / (((sqrtPrice - sqrtLow)) + (((1 / sqrtPrice) - (1 / sqrtHigh)) * (price * Math.pow(10, decimal)))); 79 | amount1 = delta * (sqrtPrice - sqrtLow); 80 | amount0 = delta * ((1 / sqrtPrice) - (1 / sqrtHigh)) * Math.pow(10, decimal); 81 | } 82 | else if (sqrtPrice < sqrtLow) { 83 | delta = investment / ((((1 / sqrtLow) - (1 / sqrtHigh)) * price)); 84 | amount1 = 0; 85 | amount0 = delta * ((1 / sqrtLow) - (1 / sqrtHigh)); 86 | } 87 | else { 88 | delta = investment / ((sqrtHigh - sqrtLow)) ; 89 | amount1 = delta * (sqrtHigh - sqrtLow); 90 | amount0 = 0; 91 | } 92 | return [amount0, amount1]; 93 | 94 | } 95 | 96 | // Calculate the liquidity share for a strategy based on the number of tokens owned 97 | export const liquidityForStrategy = (price, low, high, tokens0, tokens1, decimal0, decimal1) => { 98 | 99 | const decimal = decimal1 - decimal0; 100 | const lowHigh = [(Math.sqrt(low * Math.pow(10, decimal))) * Math.pow(2, 96), (Math.sqrt(high * Math.pow(10, decimal))) * Math.pow(2, 96)]; 101 | 102 | const sPrice = (Math.sqrt(price * Math.pow(10, decimal))) * Math.pow(2, 96); 103 | const sLow = Math.min(...lowHigh); 104 | const sHigh = Math.max(...lowHigh); 105 | 106 | if (sPrice <= sLow) { 107 | 108 | return tokens0 / (( Math.pow(2, 96) * (sHigh-sLow) / sHigh / sLow) / Math.pow(10, decimal0)); 109 | 110 | } else if (sPrice <= sHigh && sPrice > sLow) { 111 | 112 | const liq0 = tokens0 / (( Math.pow(2, 96) * (sHigh - sPrice) / sHigh / sPrice) / Math.pow(10, decimal0)); 113 | const liq1 = tokens1 / ((sPrice - sLow) / Math.pow(2, 96) / Math.pow(10, decimal1)); 114 | return Math.min(liq1, liq0); 115 | } 116 | else { 117 | 118 | return tokens1 / ((sHigh - sLow) / Math.pow(2, 96) / Math.pow(10, decimal1)); 119 | } 120 | 121 | } 122 | 123 | // Calculate estimated fees 124 | export const calcFees = (data, pool, priceToken, liquidity, unboundedLiquidity, investment, min, max) => { 125 | 126 | return data.map((d, i) => { 127 | 128 | const fg = i - 1 < 0 ? [0, 0] : calcUnboundedFees(d.feeGrowthGlobal0X128, data[(i-1)].feeGrowthGlobal0X128, d.feeGrowthGlobal1X128, data[(i-1)].feeGrowthGlobal1X128, pool); 129 | 130 | const low = priceToken === 0 ? d.low : 1 / (d.low === '0' ? 1 : d.low); 131 | const high = priceToken === 0 ? d.high : 1 / (d.high === '0' ? 1 : d.high); 132 | 133 | const lowTick = getTickFromPrice(low, pool, priceToken); 134 | const highTick = getTickFromPrice(high, pool, priceToken); 135 | const minTick = getTickFromPrice(min, pool, priceToken); 136 | const maxTick = getTickFromPrice(max, pool, priceToken); 137 | 138 | const activeLiquidity = activeLiquidityForCandle(minTick, maxTick, lowTick, highTick); 139 | const tokens = tokensFromLiquidity((priceToken === 1 ? 1 / d.close : d.close), min, max, liquidity, pool.token0.decimals, pool.token1.decimals); 140 | const feeToken0 = i === 0 ? 0 : fg[0] * liquidity * activeLiquidity / 100; 141 | const feeToken1 = i === 0 ? 0 : fg[1] * liquidity * activeLiquidity / 100; 142 | 143 | const feeUnb0 = i === 0 ? 0 : fg[0] * unboundedLiquidity; 144 | const feeUnb1 = i === 0 ? 0 : fg[1] * unboundedLiquidity; 145 | 146 | let fgV, feeV, feeUnb, amountV, feeUSD, amountTR; 147 | const latestRec = data[(data.length - 1)]; 148 | const firstClose = priceToken === 1 ? 1 / data[0].close : data[0].close; 149 | 150 | const tokenRatioFirstClose = tokensFromLiquidity(firstClose, min, max, liquidity, pool.token0.decimals, pool.token1.decimals); 151 | const x0 = tokenRatioFirstClose[1]; 152 | const y0 = tokenRatioFirstClose[0]; 153 | 154 | if (priceToken === 0) { 155 | fgV = i === 0 ? 0 : fg[0] + (fg[1] * d.close); 156 | feeV = i === 0 ? 0 : feeToken0 + (feeToken1 * d.close); 157 | feeUnb = i === 0 ? 0 : feeUnb0 + (feeUnb1 * d.close); 158 | amountV = tokens[0] + (tokens[1] * d.close); 159 | feeUSD = feeV * parseFloat(latestRec.pool.totalValueLockedUSD) / ((parseFloat(latestRec.pool.totalValueLockedToken1) * parseFloat(latestRec.close) ) + parseFloat(latestRec.pool.totalValueLockedToken0) ); 160 | amountTR = investment + (amountV - ((x0 * d.close) + y0)); 161 | } 162 | else if (priceToken === 1) { 163 | fgV = i === 0 ? 0 : (fg[0] / d.close) + fg[1]; 164 | feeV = i === 0 ? 0 : (feeToken0 / d.close ) + feeToken1; 165 | feeUnb = i === 0 ? 0 : feeUnb0 + (feeUnb1 * d.close); 166 | amountV = (tokens[1] / d.close) + tokens[0]; 167 | feeUSD = feeV * parseFloat(latestRec.pool.totalValueLockedUSD) / (parseFloat(latestRec.pool.totalValueLockedToken1) + (parseFloat(latestRec.pool.totalValueLockedToken0) / parseFloat(latestRec.close))); 168 | amountTR = investment + (amountV - ((x0 * (1 / d.close)) + y0)); 169 | } 170 | 171 | const date = new Date(d.periodStartUnix*1000); 172 | return { 173 | ...d, 174 | day: date.getUTCDate(), 175 | month: date.getUTCMonth(), 176 | year: date.getFullYear(), 177 | fg0 : fg[0], 178 | fg1 : fg[1], 179 | activeliquidity: activeLiquidity, 180 | feeToken0: feeToken0, 181 | feeToken1: feeToken1, 182 | tokens: tokens, 183 | fgV: fgV, 184 | feeV: feeV, 185 | feeUnb: feeUnb, 186 | amountV: amountV, 187 | amountTR: amountTR, 188 | feeUSD: feeUSD, 189 | close: d.close, 190 | baseClose: priceToken === 1 ? 1 / d.close : d.close 191 | } 192 | 193 | }); 194 | } 195 | 196 | // Pivot hourly estimated fee data (generated by calcFees) into daily values // 197 | export const pivotFeeData = (data, priceToken, investment) => { 198 | 199 | const createPivotRecord = (date, data) => { 200 | return { 201 | date: `${date.getUTCMonth() + 1}/${date.getUTCDate()}/${date.getFullYear()}`, 202 | day: date.getUTCDate(), 203 | month: date.getUTCMonth(), 204 | year: date.getFullYear(), 205 | feeToken0: data.feeToken0, 206 | feeToken1: data.feeToken1, 207 | feeV: data.feeV, 208 | feeUnb: data.feeUnb, 209 | fgV: parseFloat(data.fgV), 210 | feeUSD: data.feeUSD, 211 | activeliquidity: isNaN(data.activeliquidity) ? 0 : data.activeliquidity, 212 | amountV: data.amountV, 213 | amountTR: data.amountTR, 214 | amountVLast: data.amountV, 215 | percFee: data.feeV / data.amountV, 216 | close: data.close, 217 | baseClose: priceToken === 1 ? 1 / data.close : data.close, 218 | count: 1 219 | } 220 | 221 | } 222 | 223 | const firstDate = new Date(data[0].periodStartUnix*1000); 224 | const pivot = [createPivotRecord(firstDate, data[0])]; 225 | 226 | data.forEach((d, i) => { 227 | if (i > 0) { 228 | const currentDate = new Date(d.periodStartUnix * 1000); 229 | const currentPriceTick = pivot[(pivot.length - 1)]; 230 | 231 | if ( currentDate.getUTCDate() === currentPriceTick.day && currentDate.getUTCMonth() === currentPriceTick.month && currentDate.getFullYear() === currentPriceTick.year) { 232 | 233 | currentPriceTick.feeToken0 = currentPriceTick.feeToken0 + d.feeToken0; 234 | currentPriceTick.feeToken1 = currentPriceTick.feeToken1 + d.feeToken1; 235 | currentPriceTick.feeV = currentPriceTick.feeV + d.feeV; 236 | currentPriceTick.feeUnb = currentPriceTick.feeUnb + d.feeUnb; 237 | currentPriceTick.fgV = parseFloat(currentPriceTick.fgV) + parseFloat(d.fgV); 238 | currentPriceTick.feeUSD = currentPriceTick.feeUSD + d.feeUSD; 239 | currentPriceTick.activeliquidity = currentPriceTick.activeliquidity + d.activeliquidity; 240 | currentPriceTick.amountVLast = d.amountV; 241 | currentPriceTick.count = currentPriceTick.count + 1; 242 | 243 | if (i === (data.length - 1)) { 244 | currentPriceTick.activeliquidity = currentPriceTick.activeliquidity / currentPriceTick.count; 245 | currentPriceTick.percFee = currentPriceTick.feeV / currentPriceTick.amountV * 100; 246 | } 247 | } 248 | else { 249 | currentPriceTick.activeliquidity = currentPriceTick.activeliquidity / currentPriceTick.count; 250 | currentPriceTick.percFee = currentPriceTick.feeV / currentPriceTick.amountV * 100; 251 | pivot.push(createPivotRecord(currentDate, d)); 252 | } 253 | } 254 | }); 255 | return pivot; 256 | } 257 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { poolById, getPoolHourData } from './uniPoolData.mjs' 2 | import { tokensForStrategy, liquidityForStrategy, calcFees, pivotFeeData } from './backtest.mjs' 3 | 4 | const DateByDaysAgo = (days, endDate = null) => { 5 | const date = !!endDate ? new Date(endDate * 1000) : new Date(); 6 | return Math.round( (date.setDate(date.getDate() - days) / 1000 )); 7 | } 8 | 9 | // data, pool, baseID, liquidity, unboundedLiquidity, min, max, customFeeDivisor, leverage, investment, tokenRatio 10 | // Required = Pool ID, investmentAmount (token0 by default), minRange, maxRange, options = { days, protocol, baseToken } 11 | 12 | export const uniswapStrategyBacktest = async ( pool, investmentAmount, minRange, maxRange, options = {}) => { 13 | 14 | const opt = {days: 30, protocol: 0, priceToken: 0, period: "hourly", ...options }; 15 | 16 | if (pool) { 17 | const poolData = await poolById(pool); 18 | let { startTimestamp, endTimestamp, days } = opt; 19 | if (!endTimestamp) { 20 | endTimestamp = Math.floor(Date.now() / 1000); 21 | } 22 | if (!startTimestamp && days) { 23 | startTimestamp = DateByDaysAgo(days, endTimestamp); 24 | } 25 | const hourlyPriceData = await getPoolHourData(pool, startTimestamp, endTimestamp, opt.protocol); 26 | 27 | if (poolData && hourlyPriceData && hourlyPriceData.length > 0) { 28 | 29 | const backtestData = hourlyPriceData.reverse(); 30 | const entryPrice = opt.priceToken === 1 ? 1 / backtestData[0].close : backtestData[0].close 31 | const tokens = tokensForStrategy(minRange, maxRange, investmentAmount, entryPrice, poolData.token1.decimals - poolData.token0.decimals); 32 | const liquidity = liquidityForStrategy(entryPrice, minRange, maxRange, tokens[0], tokens[1], poolData.token0.decimals, poolData.token1.decimals); 33 | const unbLiquidity = liquidityForStrategy(entryPrice, Math.pow(1.0001, -887220), Math.pow(1.0001, 887220), tokens[0], tokens[1], poolData.token0.decimals, poolData.token1.decimals); 34 | const hourlyBacktest = calcFees(backtestData, poolData, opt.priceToken, liquidity, unbLiquidity, investmentAmount, minRange, maxRange); 35 | return opt.period === 'daily' ? pivotFeeData(hourlyBacktest, opt.priceToken, investmentAmount) : hourlyBacktest; 36 | } 37 | } 38 | } 39 | 40 | export const hourlyPoolData = (pool, days = 30, protocol = 0) => { 41 | getPoolHourData(pool, DateByDaysAgo(days), protocol).then(d => { 42 | if ( d && d.length ) { return d } 43 | else { return null } 44 | }) 45 | } 46 | 47 | export default uniswapStrategyBacktest 48 | -------------------------------------------------------------------------------- /numbers.mjs: -------------------------------------------------------------------------------- 1 | export const round = (number, decimalPlaces) => { 2 | const factorOfTen = Math.pow(10, decimalPlaces) 3 | return Math.round(number * factorOfTen) / factorOfTen 4 | } 5 | 6 | export const sumArray = (arr) => { 7 | return arr.reduce((a, b) => a + b, 0); 8 | } 9 | 10 | export const parsePrice = (price, percent) => { 11 | 12 | const rounder = percent ? 2 : 4; 13 | 14 | if (price === 0) { 15 | return 0; 16 | } 17 | else if (price > 1000000) { 18 | return parseInt(price); 19 | } 20 | else if (price > 1) { 21 | return round(price, 2); 22 | } 23 | else { 24 | const m = -Math.floor( Math.log(Math.abs(price)) / Math.log(10) + 1); 25 | return round(price, m + rounder); 26 | 27 | } 28 | } 29 | 30 | export const logWithBase = (y, x) => { 31 | return Math.log(y) / Math.log(x); 32 | } 33 | 34 | function getMin(data, col) { 35 | return data.reduce((min, p) => p[col] < min ? p[col] : min, data[0][col]); 36 | } 37 | 38 | function getMax(data, col) { 39 | return data.reduce((max, p) => p[col] > max ? p[col] : max, data[0][col]); 40 | } 41 | 42 | export const maxInArray = (data, col) => { 43 | let max = 0; 44 | data.forEach( (d) => { 45 | const dMax = getMax(d, col); 46 | max = dMax > max ? dMax : max; 47 | }); 48 | 49 | return max; 50 | } 51 | 52 | export const minInArray = (data, col) => { 53 | let min = 0; 54 | 55 | data.forEach( (d, i) => { 56 | const dMin = Math.round(getMin(d, col) * 100) / 100; 57 | min = dMin < min ? dMin : min; 58 | }); 59 | 60 | return min; 61 | } 62 | 63 | export const genRandBetween = (min, max, decimalPlaces) => { 64 | const rand = Math.random() * (max - min) + min; 65 | const power = Math.pow(10, decimalPlaces); 66 | return Math.floor(rand * power) / power; 67 | } 68 | 69 | export const formatLargeNumber = (n) => { 70 | 71 | const pow = Math.pow; 72 | const floor = Math.floor; 73 | const abs = Math.abs; 74 | const log = Math.log; 75 | 76 | const abbrev = 'kmb'; 77 | 78 | const baseSuff = floor(log(abs(n)) / log(1000)); 79 | const suffix = abbrev[Math.min(2, baseSuff - 1)]; 80 | const base = abbrev.indexOf(suffix) + 1; 81 | return suffix ? round(n / pow(1000,base), 2) + suffix : '' + round(n,2); 82 | 83 | } 84 | 85 | export const roundWithFactor = (number) => { 86 | const factor = String(parseInt(number)).length - 1; 87 | return Math.pow(10, factor); 88 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uniswap-v3-backtest", 3 | "version": "1.0.3", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "version": "1.0.3", 9 | "license": "MIT", 10 | "dependencies": { 11 | "node-fetch": "^3.2.4" 12 | } 13 | }, 14 | "node_modules/data-uri-to-buffer": { 15 | "version": "4.0.0", 16 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", 17 | "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==", 18 | "engines": { 19 | "node": ">= 12" 20 | } 21 | }, 22 | "node_modules/fetch-blob": { 23 | "version": "3.1.5", 24 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz", 25 | "integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==", 26 | "funding": [ 27 | { 28 | "type": "github", 29 | "url": "https://github.com/sponsors/jimmywarting" 30 | }, 31 | { 32 | "type": "paypal", 33 | "url": "https://paypal.me/jimmywarting" 34 | } 35 | ], 36 | "dependencies": { 37 | "node-domexception": "^1.0.0", 38 | "web-streams-polyfill": "^3.0.3" 39 | }, 40 | "engines": { 41 | "node": "^12.20 || >= 14.13" 42 | } 43 | }, 44 | "node_modules/formdata-polyfill": { 45 | "version": "4.0.10", 46 | "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", 47 | "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", 48 | "dependencies": { 49 | "fetch-blob": "^3.1.2" 50 | }, 51 | "engines": { 52 | "node": ">=12.20.0" 53 | } 54 | }, 55 | "node_modules/node-domexception": { 56 | "version": "1.0.0", 57 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 58 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 59 | "funding": [ 60 | { 61 | "type": "github", 62 | "url": "https://github.com/sponsors/jimmywarting" 63 | }, 64 | { 65 | "type": "github", 66 | "url": "https://paypal.me/jimmywarting" 67 | } 68 | ], 69 | "engines": { 70 | "node": ">=10.5.0" 71 | } 72 | }, 73 | "node_modules/node-fetch": { 74 | "version": "3.2.4", 75 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.4.tgz", 76 | "integrity": "sha512-WvYJRN7mMyOLurFR2YpysQGuwYrJN+qrrpHjJDuKMcSPdfFccRUla/kng2mz6HWSBxJcqPbvatS6Gb4RhOzCJw==", 77 | "dependencies": { 78 | "data-uri-to-buffer": "^4.0.0", 79 | "fetch-blob": "^3.1.4", 80 | "formdata-polyfill": "^4.0.10" 81 | }, 82 | "engines": { 83 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 84 | }, 85 | "funding": { 86 | "type": "opencollective", 87 | "url": "https://opencollective.com/node-fetch" 88 | } 89 | }, 90 | "node_modules/web-streams-polyfill": { 91 | "version": "3.2.1", 92 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", 93 | "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", 94 | "engines": { 95 | "node": ">= 8" 96 | } 97 | } 98 | }, 99 | "dependencies": { 100 | "data-uri-to-buffer": { 101 | "version": "4.0.0", 102 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", 103 | "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==" 104 | }, 105 | "fetch-blob": { 106 | "version": "3.1.5", 107 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz", 108 | "integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==", 109 | "requires": { 110 | "node-domexception": "^1.0.0", 111 | "web-streams-polyfill": "^3.0.3" 112 | } 113 | }, 114 | "formdata-polyfill": { 115 | "version": "4.0.10", 116 | "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", 117 | "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", 118 | "requires": { 119 | "fetch-blob": "^3.1.2" 120 | } 121 | }, 122 | "node-domexception": { 123 | "version": "1.0.0", 124 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 125 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" 126 | }, 127 | "node-fetch": { 128 | "version": "3.2.4", 129 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.4.tgz", 130 | "integrity": "sha512-WvYJRN7mMyOLurFR2YpysQGuwYrJN+qrrpHjJDuKMcSPdfFccRUla/kng2mz6HWSBxJcqPbvatS6Gb4RhOzCJw==", 131 | "requires": { 132 | "data-uri-to-buffer": "^4.0.0", 133 | "fetch-blob": "^3.1.4", 134 | "formdata-polyfill": "^4.0.10" 135 | } 136 | }, 137 | "web-streams-polyfill": { 138 | "version": "3.2.1", 139 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", 140 | "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uniswap-v3-backtest", 3 | "version": "1.1.2", 4 | "description": "Fast and efficient method for testing Uniswap V3 LP Strategies", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \\\"Error: no test specified\\\" && exit 1" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/DefiLab-xyz/uniswap-v3-backtest.git" 13 | }, 14 | "keywords": [ 15 | "uniswap", 16 | "uniswapV3", 17 | "LP Strategy Backtester", 18 | "backtester", 19 | "defi", 20 | "backtest" 21 | ], 22 | "author": "staceb", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/DefiLab-xyz/uniswap-v3-backtest/issues" 26 | }, 27 | "homepage": "https://github.com/DefiLab-xyz/uniswap-v3-backtest#readme", 28 | "dependencies": { 29 | "node-fetch": "^3.2.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /uniPoolData.mjs: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | const urlForProtocol = (protocol) => { 4 | return protocol === 1 ? "https://api.thegraph.com/subgraphs/name/ianlapham/optimism-post-regenesis" : 5 | protocol === 2 ? "https://api.thegraph.com/subgraphs/name/ianlapham/arbitrum-minimal" : 6 | protocol === 3 ? "https://api.thegraph.com/subgraphs/name/ianlapham/uniswap-v3-polygon" : 7 | protocol === 4 ? "https://api.thegraph.com/subgraphs/name/perpetual-protocol/perpetual-v2-optimism" : 8 | "https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3"; 9 | } 10 | 11 | const requestBody = (request) => { 12 | 13 | if(!request.query) return; 14 | 15 | const body = { 16 | method:'POST', 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | }, 20 | body: JSON.stringify({ 21 | query: request.query, 22 | variables: request.variables || {} 23 | }) 24 | } 25 | 26 | if (request.signal) body.signal = request.signal; 27 | return body; 28 | 29 | } 30 | 31 | export const getPoolHourData = async (pool, fromdate, todate, protocol) => { 32 | 33 | const query = `query PoolHourDatas($pool: ID!, $fromdate: Int!, $todate: Int!) { 34 | poolHourDatas ( where:{ pool:$pool, periodStartUnix_gt:$fromdate periodStartUnix_lt:$todate close_gt: 0}, orderBy:periodStartUnix, orderDirection:desc, first:1000) { 35 | periodStartUnix 36 | liquidity 37 | high 38 | low 39 | pool { 40 | id 41 | totalValueLockedUSD 42 | totalValueLockedToken1 43 | totalValueLockedToken0 44 | token0 45 | {decimals} 46 | token1 47 | {decimals} 48 | } 49 | close 50 | feeGrowthGlobal0X128 51 | feeGrowthGlobal1X128 52 | } 53 | } 54 | ` 55 | 56 | const url = urlForProtocol(protocol); 57 | 58 | try { 59 | const response = await fetch(url, requestBody({query: query, variables: {pool: pool, fromdate: fromdate, todate} })); 60 | const data = await response.json(); 61 | 62 | if (data && data.data && data.data.poolHourDatas) { 63 | 64 | return data.data.poolHourDatas; 65 | } 66 | else { 67 | console.log("nothing returned from getPoolHourData") 68 | return null; 69 | } 70 | 71 | } catch (error) { 72 | return {error: error}; 73 | } 74 | 75 | } 76 | 77 | 78 | export const poolById = async (id, protocol) => { 79 | 80 | const url = urlForProtocol(protocol); 81 | 82 | const poolQueryFields = `{ 83 | id 84 | feeTier 85 | totalValueLockedUSD 86 | totalValueLockedETH 87 | token0Price 88 | token1Price 89 | token0 { 90 | id 91 | symbol 92 | name 93 | decimals 94 | } 95 | token1 { 96 | id 97 | symbol 98 | name 99 | decimals 100 | } 101 | poolDayData(orderBy: date, orderDirection:desc,first:1) 102 | { 103 | date 104 | volumeUSD 105 | tvlUSD 106 | feesUSD 107 | liquidity 108 | high 109 | low 110 | volumeToken0 111 | volumeToken1 112 | close 113 | open 114 | } 115 | }` 116 | 117 | const query = `query Pools($id: ID!) { id: pools(where: { id: $id } orderBy:totalValueLockedETH, orderDirection:desc) 118 | ${poolQueryFields} 119 | }` 120 | 121 | try { 122 | 123 | const response = await fetch(url, requestBody({query: query, variables: {id: id}})); 124 | const data = await response.json(); 125 | 126 | if (data && data.data) { 127 | const pools = data.data; 128 | 129 | if (pools.id && pools.id.length && pools.id.length === 1) { 130 | return pools.id[0] 131 | } 132 | } 133 | else { 134 | return null; 135 | } 136 | 137 | } catch (error) { 138 | return {error: error}; 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /uniPools.mjs: -------------------------------------------------------------------------------- 1 | import { minTvl, urlForProtocol, requestBody } from "./helpers"; 2 | 3 | export const PoolCurrentPrices = async (signal, protocol, pool) => { 4 | const url = urlForProtocol(protocol); 5 | 6 | const query = ` 7 | query Pools($pool: ID!) { pools (first:1, where: {id: $pool}) 8 | { 9 | token0Price 10 | token1Price 11 | token0 { 12 | id 13 | symbol 14 | } 15 | token1 { 16 | id 17 | symbol 18 | } 19 | } 20 | } 21 | ` 22 | try { 23 | const response = await fetch(url, requestBody({query: query, variables: {pool: pool}, signal: signal})); 24 | const data = await response.json(); 25 | 26 | if (data && data.data && data.data.pools) { 27 | return data.data.pools[0]; 28 | } 29 | else { 30 | return null; 31 | } 32 | 33 | } catch (error) { 34 | return {error: error}; 35 | } 36 | 37 | } 38 | 39 | const poolQueryFields = `{ 40 | id 41 | feeTier 42 | totalValueLockedUSD 43 | totalValueLockedETH 44 | token0Price 45 | token1Price 46 | token0 { 47 | id 48 | symbol 49 | name 50 | decimals 51 | } 52 | token1 { 53 | id 54 | symbol 55 | name 56 | decimals 57 | } 58 | poolDayData(orderBy: date, orderDirection:desc,first:1) 59 | { 60 | date 61 | volumeUSD 62 | tvlUSD 63 | feesUSD 64 | liquidity 65 | high 66 | low 67 | volumeToken0 68 | volumeToken1 69 | close 70 | open 71 | } 72 | }` 73 | 74 | export const top50PoolsByTvl = async (signal, protocol) => { 75 | 76 | const url = urlForProtocol(protocol); 77 | 78 | const query = ` 79 | query { pools (first:50, where: {totalValueLockedUSD_gt: ${minTvl(protocol)}} , orderBy:totalValueLockedETH, orderDirection:desc) 80 | ${poolQueryFields} 81 | }` 82 | 83 | try { 84 | 85 | const response = await fetch(url, requestBody({query: query, signal: signal})); 86 | const data = await response.json(); 87 | // console.log(data) 88 | if (data && data.data && data.data.pools) { 89 | return data.data.pools; 90 | } 91 | else { 92 | return null; 93 | } 94 | 95 | } catch (error) { 96 | return {error: error}; 97 | } 98 | } 99 | 100 | export const poolById = async (id, signal, protocol) => { 101 | 102 | const url = urlForProtocol(protocol); 103 | 104 | const query = `query Pools($id: ID!) { id: pools(where: { id: $id } orderBy:totalValueLockedETH, orderDirection:desc) 105 | ${poolQueryFields} 106 | }` 107 | 108 | try { 109 | 110 | const response = await fetch(url, requestBody({query: query, variables: {id: id}, signal: signal})); 111 | const data = await response.json(); 112 | 113 | if (data && data.data) { 114 | const pools = data.data; 115 | 116 | if (pools.id && pools.id.length && pools.id.length === 1) { 117 | return pools.id[0] 118 | } 119 | } 120 | else { 121 | return null; 122 | } 123 | 124 | } catch (error) { 125 | return {error: error}; 126 | } 127 | 128 | } 129 | 130 | export const poolByIds = async (ids, signal, protocol) => { 131 | 132 | const url = urlForProtocol(protocol); 133 | const query = `query Pools($ids: [Bytes]!) { id: pools(where: { id_in: $ids } orderBy:totalValueLockedETH, orderDirection:desc) 134 | ${poolQueryFields} 135 | }` 136 | 137 | try { 138 | 139 | const response = await fetch(url, requestBody({query: query, variables: {ids: ids}, signal: signal})); 140 | const data = await response.json(); 141 | 142 | if (data && data.data && data.data.id) { 143 | return data.data.id 144 | 145 | } 146 | else { 147 | return null; 148 | } 149 | 150 | } catch (error) { 151 | return {error: error}; 152 | } 153 | 154 | } 155 | 156 | export const poolsByTokenId = async (token, signal, protocol) => { 157 | 158 | const url = urlForProtocol(protocol); 159 | 160 | const query = `query Pools($token: ID!) { 161 | pools(where: { token1: $token, totalValueLockedUSD_gt: ${minTvl(protocol)}} orderBy:totalValueLockedETH, orderDirection:desc ) 162 | ${poolQueryFields} 163 | pools(where: { token0: $token, totalValueLockedUSD_gt: ${minTvl(protocol)}}, orderBy:totalValueLockedETH, orderDirection:desc ) 164 | ${poolQueryFields} 165 | id: pools(where: { id: $token } orderBy:totalValueLockedETH, orderDirection:desc ) ${poolQueryFields} 166 | }` 167 | 168 | try { 169 | 170 | const response = await fetch(url, requestBody({query: query, variables: {token: token}, signal: signal})); 171 | const data = await response.json(); 172 | 173 | if (data && data.data) { 174 | const pools = data.data; 175 | 176 | if (pools.id && pools.id.length && pools.id.length === 1) { 177 | return pools.id; 178 | } 179 | else if (pools.pools) { 180 | return pools.pools; 181 | } 182 | } 183 | else { 184 | return null; 185 | } 186 | 187 | } catch (error) { 188 | return {error: error}; 189 | } 190 | 191 | } 192 | 193 | export const poolsByTokenIds = async (tokens, signal, protocol) => { 194 | 195 | const url = urlForProtocol(protocol); 196 | const query = `query Pools($tokens: [Bytes]!) { 197 | token1: pools( where: { token1_in: $tokens, totalValueLockedUSD_gt: ${minTvl(protocol)}}, orderBy:totalValueLockedETH, orderDirection:desc ) 198 | ${poolQueryFields} 199 | token2: pools( where: { token0_in: $tokens, totalValueLockedUSD_gt: ${minTvl(protocol)}} orderBy:totalValueLockedETH, orderDirection:desc ) 200 | ${poolQueryFields} 201 | }` 202 | 203 | try { 204 | 205 | const response = await fetch(url, requestBody({query: query, variables: {tokens: tokens}, signal: signal})); 206 | const data = await response.json(); 207 | 208 | if (data && data.data && (data.data.token1 || data.data.token2)) { 209 | 210 | if (data.data.token1 && data.data.token2) { 211 | 212 | const d = data.data.token1.concat(data.data.token2).sort( (a, b) => { 213 | return parseFloat(a.totalValueLockedETH) > parseFloat(b.totalValueLockedETH) ? -1 : 1; 214 | }); 215 | 216 | const removeDupes = d.filter((el, i) => { 217 | return d.findIndex(f => f.id === el.id) === i 218 | }); 219 | 220 | return removeDupes; 221 | } 222 | else if (data.data.token1) { 223 | return data.data.token1; 224 | } 225 | else if (data.data.token2) { 226 | return data.data.token2; 227 | } 228 | 229 | } 230 | else { 231 | return null; 232 | } 233 | 234 | } catch (error) { 235 | return {error: error}; 236 | } 237 | 238 | } --------------------------------------------------------------------------------