From 29efa766ca8b9b87689f2cc844e62685073fd569 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 20 Aug 2025 10:54:54 +0200 Subject: [PATCH] Feat: Add liquidity calculation utility export to Dex module Signed-off-by: Jakub Dzikowski --- src/api/utils/dex/index.ts | 1 + .../utils/dex/liquidityCalculation.example.ts | 166 +++++++++++++++++ .../dex/liquidityCalculation.util.spec.ts | 174 ++++++++++++++++++ .../utils/dex/liquidityCalculation.util.ts | 138 ++++++++++++++ 4 files changed, 479 insertions(+) create mode 100644 src/api/utils/dex/liquidityCalculation.example.ts create mode 100644 src/api/utils/dex/liquidityCalculation.util.spec.ts create mode 100644 src/api/utils/dex/liquidityCalculation.util.ts diff --git a/src/api/utils/dex/index.ts b/src/api/utils/dex/index.ts index 4f8466c..f96310f 100644 --- a/src/api/utils/dex/index.ts +++ b/src/api/utils/dex/index.ts @@ -20,3 +20,4 @@ export * from "./sqrtPriceMath.helper"; export * from "./swapMath.helper"; export * from "./tick.helper"; export * from "./dexTypes"; +export * from "./liquidityCalculation.util"; diff --git a/src/api/utils/dex/liquidityCalculation.example.ts b/src/api/utils/dex/liquidityCalculation.example.ts new file mode 100644 index 0000000..b6fcb57 --- /dev/null +++ b/src/api/utils/dex/liquidityCalculation.example.ts @@ -0,0 +1,166 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; + +import { + LiquidityPosition, + calculateActiveLiquidityPercentage, + calculateAggregatedTokenAmounts, + calculatePositionTokenAmounts, + getPositionStatus +} from "./liquidityCalculation.util"; + +/** + * Example usage of the liquidity calculation utility functions + * This demonstrates how to calculate Uniswap V3 liquidity positions + */ + +// Example 1: Calculate a single position's token amounts +function exampleSinglePosition() { + console.log("=== Example 1: Single Position Calculation ==="); + + const position: LiquidityPosition = { + liquidity: new BigNumber("1000000"), // 1M liquidity units + tickLower: 1000, // Lower tick bound + tickUpper: 2000, // Upper tick bound + sqrtPriceCurrent: new BigNumber("1.0001").pow(1500), // Current price between ticks + galaIsToken0: true // GALA is token0 in this pool + }; + + const amounts = calculatePositionTokenAmounts(position); + + console.log(`Position Details:`); + console.log(`- Liquidity: ${position.liquidity.toString()}`); + console.log(`- Tick Range: ${position.tickLower} to ${position.tickUpper}`); + console.log(`- Current Price: ${position.sqrtPriceCurrent.toString()}`); + console.log(`- GALA is Token0: ${position.galaIsToken0}`); + + console.log(`\nCalculated Amounts:`); + console.log(`- Token0 (GALA): ${amounts.amount0.toString()}`); + console.log(`- Token1: ${amounts.amount1.toString()}`); + console.log(`- GALA Amount: ${amounts.galaAmount.toString()}`); + + const status = getPositionStatus(position.sqrtPriceCurrent, position.tickLower, position.tickUpper); + const activePercentage = calculateActiveLiquidityPercentage(position); + + console.log(`\nPosition Status:`); + console.log(`- Status: ${status}`); + console.log(`- Active Liquidity: ${activePercentage}%`); +} + +// Example 2: Calculate multiple positions and aggregate +function exampleMultiplePositions() { + console.log("\n=== Example 2: Multiple Positions Aggregation ==="); + + const positions: LiquidityPosition[] = [ + { + liquidity: new BigNumber("500000"), + tickLower: 800, + tickUpper: 1200, + sqrtPriceCurrent: new BigNumber("1.0001").pow(1000), + galaIsToken0: true + }, + { + liquidity: new BigNumber("750000"), + tickLower: 1500, + tickUpper: 2500, + sqrtPriceCurrent: new BigNumber("1.0001").pow(2000), + galaIsToken0: false + }, + { + liquidity: new BigNumber("300000"), + tickLower: 3000, + tickUpper: 4000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(3500), + galaIsToken0: true + } + ]; + + const aggregated = calculateAggregatedTokenAmounts(positions); + + console.log(`Aggregated Results:`); + console.log(`- Total Positions: ${aggregated.positionCount}`); + console.log(`- Total Token0: ${aggregated.totalAmount0.toString()}`); + console.log(`- Total Token1: ${aggregated.totalAmount1.toString()}`); + console.log(`- Total GALA: ${aggregated.totalGalaAmount.toString()}`); + + // Show individual position details + positions.forEach((pos, index) => { + const amounts = calculatePositionTokenAmounts(pos); + const status = getPositionStatus(pos.sqrtPriceCurrent, pos.tickLower, pos.tickUpper); + + console.log(`\nPosition ${index + 1}:`); + console.log(`- Status: ${status}`); + console.log(`- GALA Amount: ${amounts.galaAmount.toString()}`); + }); +} + +// Example 3: Different price range scenarios +function examplePriceScenarios() { + console.log("\n=== Example 3: Different Price Range Scenarios ==="); + + const scenarios = [ + { + name: "Below Range (All Token1)", + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(500), // Below lower tick + galaIsToken0: false + }, + { + name: "In Range (Both Tokens)", + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(1500), // Between ticks + galaIsToken0: true + }, + { + name: "Above Range (All Token0)", + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(2500), // Above upper tick + galaIsToken0: true + } + ]; + + scenarios.forEach((scenario) => { + const position: LiquidityPosition = { + liquidity: new BigNumber("1000000"), + tickLower: scenario.tickLower, + tickUpper: scenario.tickUpper, + sqrtPriceCurrent: scenario.sqrtPriceCurrent, + galaIsToken0: scenario.galaIsToken0 + }; + + const amounts = calculatePositionTokenAmounts(position); + const status = getPositionStatus(position.sqrtPriceCurrent, position.tickLower, position.tickUpper); + + console.log(`\n${scenario.name}:`); + console.log(`- Status: ${status}`); + console.log(`- Token0: ${amounts.amount0.toString()}`); + console.log(`- Token1: ${amounts.amount1.toString()}`); + console.log(`- GALA Amount: ${amounts.galaAmount.toString()}`); + }); +} + +// Run all examples +export function runExamples() { + exampleSinglePosition(); + exampleMultiplePositions(); + examplePriceScenarios(); +} + +// Uncomment to run examples +// runExamples(); diff --git a/src/api/utils/dex/liquidityCalculation.util.spec.ts b/src/api/utils/dex/liquidityCalculation.util.spec.ts new file mode 100644 index 0000000..ea13983 --- /dev/null +++ b/src/api/utils/dex/liquidityCalculation.util.spec.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; + +import { + LiquidityPosition, + calculateActiveLiquidityPercentage, + calculateAggregatedTokenAmounts, + calculatePositionTokenAmounts, + getPositionStatus +} from "./liquidityCalculation.util"; + +describe("Liquidity Calculation Utility", () => { + describe("calculatePositionTokenAmounts", () => { + it("calculates token amounts for position below range (entirely token1)", () => { + // Position below range: current price < lower tick + const position: LiquidityPosition = { + liquidity: new BigNumber("1000"), + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(500), // Below lower tick + galaIsToken0: false // GALA is token1 + }; + + const result = calculatePositionTokenAmounts(position); + + // When below range, token0 = 0, token1 = L * (sqrt(upper) - sqrt(lower)) + expect(result.amount0.toNumber()).toBe(0); + expect(result.amount1.toNumber()).toBeGreaterThan(0); + expect(result.galaAmount.toNumber()).toBeGreaterThan(0); + expect(result.galaAmount.eq(result.amount1)).toBe(true); + }); + + it("calculates token amounts for position above range (entirely token0)", () => { + // Position above range: current price > upper tick + const position: LiquidityPosition = { + liquidity: new BigNumber("1000"), + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(2500), // Above upper tick + galaIsToken0: true // GALA is token0 + }; + + const result = calculatePositionTokenAmounts(position); + + // When above range, token0 = L * (1/sqrt(lower) - 1/sqrt(upper)), token1 = 0 + expect(result.amount0.toNumber()).toBeGreaterThan(0); + expect(result.amount1.toNumber()).toBe(0); + expect(result.galaAmount.toNumber()).toBeGreaterThan(0); + expect(result.galaAmount.eq(result.amount0)).toBe(true); + }); + + it("calculates token amounts for position in range (both tokens)", () => { + // Position in range: lower tick < current price < upper tick + const position: LiquidityPosition = { + liquidity: new BigNumber("1000"), + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(1500), // Between ticks + galaIsToken0: true // GALA is token0 + }; + + const result = calculatePositionTokenAmounts(position); + + // When in range, both tokens should have amounts + expect(result.amount0.toNumber()).toBeGreaterThan(0); + expect(result.amount1.toNumber()).toBeGreaterThan(0); + expect(result.galaAmount.toNumber()).toBeGreaterThan(0); + expect(result.galaAmount.eq(result.amount0)).toBe(true); + }); + }); + + describe("calculateAggregatedTokenAmounts", () => { + it("aggregates multiple positions correctly", () => { + const positions: LiquidityPosition[] = [ + { + liquidity: new BigNumber("1000"), + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(1500), + galaIsToken0: true + }, + { + liquidity: new BigNumber("2000"), + tickLower: 500, + tickUpper: 1500, + sqrtPriceCurrent: new BigNumber("1.0001").pow(1000), + galaIsToken0: false + } + ]; + + const result = calculateAggregatedTokenAmounts(positions); + + expect(result.positionCount).toBe(2); + expect(result.totalAmount0.toNumber()).toBeGreaterThan(0); + expect(result.totalAmount1.toNumber()).toBeGreaterThan(0); + expect(result.totalGalaAmount.toNumber()).toBeGreaterThan(0); + }); + }); + + describe("getPositionStatus", () => { + it("correctly identifies position below range", () => { + const sqrtPriceCurrent = new BigNumber("1.0001").pow(500); + const tickLower = 1000; + const tickUpper = 2000; + + const status = getPositionStatus(sqrtPriceCurrent, tickLower, tickUpper); + + expect(status).toBe("below_range"); + }); + + it("correctly identifies position in range", () => { + const sqrtPriceCurrent = new BigNumber("1.0001").pow(1500); + const tickLower = 1000; + const tickUpper = 2000; + + const status = getPositionStatus(sqrtPriceCurrent, tickLower, tickUpper); + + expect(status).toBe("in_range"); + }); + + it("correctly identifies position above range", () => { + const sqrtPriceCurrent = new BigNumber("1.0001").pow(2500); + const tickLower = 1000; + const tickUpper = 2000; + + const status = getPositionStatus(sqrtPriceCurrent, tickLower, tickUpper); + + expect(status).toBe("above_range"); + }); + }); + + describe("calculateActiveLiquidityPercentage", () => { + it("returns 100% for in-range positions", () => { + const position: LiquidityPosition = { + liquidity: new BigNumber("1000"), + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(1500), + galaIsToken0: true + }; + + const percentage = calculateActiveLiquidityPercentage(position); + + expect(percentage).toBe(100); + }); + + it("returns 50% for out-of-range positions", () => { + const position: LiquidityPosition = { + liquidity: new BigNumber("1000"), + tickLower: 1000, + tickUpper: 2000, + sqrtPriceCurrent: new BigNumber("1.0001").pow(500), // Below range + galaIsToken0: true + }; + + const percentage = calculateActiveLiquidityPercentage(position); + + expect(percentage).toBe(50); + }); + }); +}); diff --git a/src/api/utils/dex/liquidityCalculation.util.ts b/src/api/utils/dex/liquidityCalculation.util.ts new file mode 100644 index 0000000..212b3ea --- /dev/null +++ b/src/api/utils/dex/liquidityCalculation.util.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; + +import { getAmountsForLiquidity } from "./addLiquidity.helper"; +import { tickToSqrtPrice } from "./tick.helper"; + +export interface LiquidityPosition { + liquidity: BigNumber; + tickLower: number; + tickUpper: number; + sqrtPriceCurrent: BigNumber; + galaIsToken0: boolean; +} + +export interface TokenAmounts { + amount0: BigNumber; + amount1: BigNumber; + galaAmount: BigNumber; +} + +/** + * Calculates the exact token amounts for a Uniswap V3 liquidity position + * using the proper mathematical formulas. + * + * @param position - The liquidity position data + * @returns Object containing amount0, amount1, and the GALA amount + */ +export function calculatePositionTokenAmounts(position: LiquidityPosition): TokenAmounts { + const { liquidity, tickLower, tickUpper, sqrtPriceCurrent, galaIsToken0 } = position; + + // Convert ticks to sqrt prices + const sqrtPriceLower = tickToSqrtPrice(tickLower); + const sqrtPriceUpper = tickToSqrtPrice(tickUpper); + + // Use the existing Uniswap V3 math function + const [amount0, amount1] = getAmountsForLiquidity( + sqrtPriceCurrent, + sqrtPriceLower, + sqrtPriceUpper, + liquidity + ); + + // Determine which amount represents GALA based on token ordering + const galaAmount = galaIsToken0 ? amount0 : amount1; + + return { + amount0, + amount1, + galaAmount + }; +} + +/** + * Calculates token amounts for multiple positions and aggregates them + * + * @param positions - Array of liquidity positions + * @returns Aggregated token amounts across all positions + */ +export function calculateAggregatedTokenAmounts(positions: LiquidityPosition[]): { + totalAmount0: BigNumber; + totalAmount1: BigNumber; + totalGalaAmount: BigNumber; + positionCount: number; +} { + let totalAmount0 = new BigNumber(0); + let totalAmount1 = new BigNumber(0); + let totalGalaAmount = new BigNumber(0); + + for (const position of positions) { + const amounts = calculatePositionTokenAmounts(position); + totalAmount0 = totalAmount0.plus(amounts.amount0); + totalAmount1 = totalAmount1.plus(amounts.amount1); + totalGalaAmount = totalGalaAmount.plus(amounts.galaAmount); + } + + return { + totalAmount0, + totalAmount1, + totalGalaAmount, + positionCount: positions.length + }; +} + +/** + * Helper function to determine if a position is in range, above range, or below range + * + * @param sqrtPriceCurrent - Current sqrt price + * @param tickLower - Lower tick bound + * @param tickUpper - Upper tick bound + * @returns Position status: 'below_range', 'in_range', or 'above_range' + */ +export function getPositionStatus( + sqrtPriceCurrent: BigNumber, + tickLower: number, + tickUpper: number +): "below_range" | "in_range" | "above_range" { + const sqrtPriceLower = tickToSqrtPrice(tickLower); + const sqrtPriceUpper = tickToSqrtPrice(tickUpper); + + if (sqrtPriceCurrent.lte(sqrtPriceLower)) { + return "below_range"; + } else if (sqrtPriceCurrent.lt(sqrtPriceUpper)) { + return "in_range"; + } else { + return "above_range"; + } +} + +/** + * Calculates the percentage of liquidity that is currently active (in range) + * + * @param position - The liquidity position data + * @returns Percentage of liquidity that is currently active (0-100) + */ +export function calculateActiveLiquidityPercentage(position: LiquidityPosition): number { + const status = getPositionStatus(position.sqrtPriceCurrent, position.tickLower, position.tickUpper); + + if (status === "in_range") { + // When in range, both tokens are active + return 100; + } else { + // When out of range, only one token is active + return 50; + } +}