From c9f8780b93d8458f4fce637272f645e890178d3f Mon Sep 17 00:00:00 2001 From: aalavandhann <6264334+aalavandhan@users.noreply.github.com> Date: Sun, 27 Oct 2024 21:36:07 -0400 Subject: [PATCH] Scalable usdc spot strategy Apply suggestions from code review Co-authored-by: Brandon Iles review fix caching bool instead of prev deviation --- .../contracts/_utils/AlphaVaultHelpers.sol | 9 +- .../contracts/charm/UsdcSpotManager.sol | 170 ++++++-- spot-vaults/test/UsdcSpotManager.ts | 365 ++++++++++++------ 3 files changed, 389 insertions(+), 155 deletions(-) diff --git a/spot-vaults/contracts/_utils/AlphaVaultHelpers.sol b/spot-vaults/contracts/_utils/AlphaVaultHelpers.sol index d738bcf6..5cf119fb 100644 --- a/spot-vaults/contracts/_utils/AlphaVaultHelpers.sol +++ b/spot-vaults/contracts/_utils/AlphaVaultHelpers.sol @@ -44,7 +44,6 @@ library AlphaVaultHelpers { if (percToRemove <= 0) { return; } - int24 _fullLower = vault.fullLower(); int24 _fullUpper = vault.fullUpper(); int24 _baseLower = vault.baseLower(); @@ -61,8 +60,12 @@ library AlphaVaultHelpers { ); // docs: https://learn.charm.fi/charm/technical-references/core/alphaprovault#emergencyburn // We remove the calculated percentage of base and full range liquidity. - vault.emergencyBurn(_fullLower, _fullUpper, fullLiquidityToBurn); - vault.emergencyBurn(_baseLower, _baseUpper, baseLiquidityToBurn); + if (fullLiquidityToBurn > 0) { + vault.emergencyBurn(_fullLower, _fullUpper, fullLiquidityToBurn); + } + if (baseLiquidityToBurn > 0) { + vault.emergencyBurn(_baseLower, _baseUpper, baseLiquidityToBurn); + } } /// @dev A low-level method, which interacts directly with the vault and executes diff --git a/spot-vaults/contracts/charm/UsdcSpotManager.sol b/spot-vaults/contracts/charm/UsdcSpotManager.sol index 51f64968..17056faf 100644 --- a/spot-vaults/contracts/charm/UsdcSpotManager.sol +++ b/spot-vaults/contracts/charm/UsdcSpotManager.sol @@ -1,8 +1,11 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.24; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { AlphaVaultHelpers } from "../_utils/AlphaVaultHelpers.sol"; +import { Range } from "../_interfaces/types/CommonTypes.sol"; import { IMetaOracle } from "../_interfaces/IMetaOracle.sol"; import { IAlphaProVault } from "../_interfaces/external/IAlphaProVault.sol"; @@ -10,10 +13,20 @@ import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3 /// @title UsdcSpotManager /// @notice This contract is a programmatic manager for the USDC-SPOT Charm AlphaProVault. +/// +/// @dev The vault's active zone is defined as lower and upper percentages of FMV. +/// For example, if the active zone is [0.95, 1.05]x and SPOT's FMV price is $1.35. +/// When the market price of SPOT is between [$1.28, $1.41] we consider price to be in the active zone. +/// +/// When in the active zone, the vault provides concentrated liquidity around the market price. +/// When price is outside the active zone, the vault reverts to a full range position. +/// +/// contract UsdcSpotManager is Ownable { //------------------------------------------------------------------------- // Libraries using AlphaVaultHelpers for IAlphaProVault; + using Math for uint256; //------------------------------------------------------------------------- // Constants & Immutables @@ -22,6 +35,10 @@ contract UsdcSpotManager is Ownable { uint256 public constant DECIMALS = 18; uint256 public constant ONE = (10 ** DECIMALS); + /// @dev Vault parameter to set max full range weight (100%). + uint24 public constant VAULT_MAX_FRW = (10 ** 6); + int24 public constant POOL_MAX_TICK = 48000; // (-99.2/+12048.1%) + /// @notice The USDC-SPOT charm alpha vault. IAlphaProVault public immutable VAULT; @@ -34,8 +51,22 @@ contract UsdcSpotManager is Ownable { /// @notice The meta oracle which returns prices of AMPL asset family. IMetaOracle public oracle; - /// @notice The recorded deviation factor at the time of the last successful rebalance operation. - uint256 public prevDeviation; + /// @notice The lower and upper deviation factor within which + /// SPOT's price is considered to be in the active zone. + Range public activeZoneDeviation; + + /// @notice The width of concentrated liquidity band, + /// SPOT's price is in the active zone. + uint256 public concBandDeviationWidth; + + /// @notice The maximum USDC balance of the vault's full range position. + uint256 public fullRangeMaxUsdcBal; + + /// @notice The maximum percentage of vault's balanced assets in the full range position. + uint256 public fullRangeMaxPerc; + + /// @notice If price was within the active zone at the time of the last successful rebalance operation. + bool public prevWithinActiveZone; //----------------------------------------------------------------------------- // Constructor and Initializer @@ -49,7 +80,14 @@ contract UsdcSpotManager is Ownable { updateOracle(oracle_); - prevDeviation = 0; + prevWithinActiveZone = false; + activeZoneDeviation = Range({ + lower: ((ONE * 95) / 100), // 0.95 or 95% + upper: ((ONE * 105) / 100) // 1.05 or 105% + }); + concBandDeviationWidth = (ONE / 20); // 0.05 or 5% + fullRangeMaxUsdcBal = 250000 * (10 ** 6); // 250k USDC + fullRangeMaxPerc = (ONE / 2); // 0.5 or 50% } //-------------------------------------------------------------------------- @@ -62,18 +100,6 @@ contract UsdcSpotManager is Ownable { oracle = oracle_; } - /// @notice Updates the vault's liquidity range parameters. - function setLiquidityRanges( - int24 baseThreshold, - uint24 fullRangeWeight, - int24 limitThreshold - ) external onlyOwner { - // Update liquidity parameters on the vault. - VAULT.setBaseThreshold(baseThreshold); - VAULT.setFullRangeWeight(fullRangeWeight); - VAULT.setLimitThreshold(limitThreshold); - } - /// @notice Forwards the given calldata to the vault. /// @param callData The calldata to pass to the vault. /// @return The data returned by the vault method call. @@ -87,54 +113,73 @@ contract UsdcSpotManager is Ownable { return r; } + /// @notice Updates the active zone definition. + function updateActiveZone(Range memory activeZoneDeviation_) external onlyOwner { + activeZoneDeviation = activeZoneDeviation_; + } + + /// @notice Updates the width of the concentrated liquidity band. + function updateConcentratedBand(uint256 concBandDeviationWidth_) external onlyOwner { + concBandDeviationWidth = concBandDeviationWidth_; + } + + /// @notice Updates the absolute and percentage maximum amount of liquidity + /// in the full range liquidity band. + function updateFullRangeLiquidity( + uint256 fullRangeMaxUsdcBal_, + uint256 fullRangeMaxPerc_ + ) external onlyOwner { + // solhint-disable-next-line custom-errors + require(fullRangeMaxPerc_ <= ONE, "InvalidPerc"); + fullRangeMaxUsdcBal = fullRangeMaxUsdcBal_; + fullRangeMaxPerc = fullRangeMaxPerc_; + } + //-------------------------------------------------------------------------- // External write methods /// @notice Executes vault rebalance. function rebalance() public { (uint256 deviation, bool deviationValid) = oracle.spotPriceDeviation(); + bool withinActiveZone = (deviationValid && activeZone(deviation)); + bool shouldForceRebalance = (withinActiveZone != prevWithinActiveZone); + + // Set liquidity parameters. + withinActiveZone ? _setupActiveZoneLiq(deviation) : _resetLiq(); // Execute rebalance. // NOTE: the vault.rebalance() will revert if enough time has not elapsed. // We thus override with a force rebalance. // https://learn.charm.fi/charm/technical-references/core/alphaprovault#rebalance - (deviationValid && shouldForceRebalance(deviation, prevDeviation)) - ? VAULT.forceRebalance() - : VAULT.rebalance(); + shouldForceRebalance ? VAULT.forceRebalance() : VAULT.rebalance(); // Trim positions after rebalance. - if (!deviationValid || shouldRemoveLimitRange(deviation)) { + if (!withinActiveZone) { + VAULT.trimLiquidity(POOL, ONE - activeFullRangePerc(), ONE); VAULT.removeLimitLiquidity(POOL); } // Update valid rebalance state. if (deviationValid) { - prevDeviation = deviation; + prevWithinActiveZone = withinActiveZone; } } //----------------------------------------------------------------------------- - // External/Public view methods - - /// @notice Checks if a rebalance has to be forced. - function shouldForceRebalance( - uint256 deviation, - uint256 prevDeviation_ - ) public pure returns (bool) { - // We rebalance if the deviation factor has crossed ONE (in either direction). - return ((deviation <= ONE && prevDeviation_ > ONE) || - (deviation >= ONE && prevDeviation_ < ONE)); + // External/Public read methods + + /// @notice Based on the given deviation factor, + /// calculates if the pool needs to be in the active zone. + function activeZone(uint256 deviation) public view returns (bool) { + return (activeZoneDeviation.lower <= deviation && + deviation <= activeZoneDeviation.upper); } - /// @notice Checks if limit range liquidity needs to be removed. - function shouldRemoveLimitRange(uint256 deviation) public view returns (bool) { - // We only activate the limit range liquidity, when - // the vault sells SPOT and deviation is above ONE, or when - // the vault buys SPOT and deviation is below ONE - bool extraSpot = isOverweightSpot(); - bool activeLimitRange = ((deviation >= ONE && extraSpot) || - (deviation <= ONE && !extraSpot)); - return (!activeLimitRange); + /// @notice Computes the percentage of liquidity to be deployed into the full range, + /// based on owner defined maximums. + function activeFullRangePerc() public view returns (uint256) { + (uint256 usdcBal, ) = VAULT.getTotalAmounts(); + return Math.min(ONE.mulDiv(fullRangeMaxUsdcBal, usdcBal), fullRangeMaxPerc); } /// @notice Checks the vault is overweight SPOT and looking to sell the extra SPOT for USDC. @@ -144,8 +189,55 @@ contract UsdcSpotManager is Ownable { return VAULT.isUnderweightToken0(); } + /// @notice Calculates the Univ3 tick equivalent of the given deviation factor. + function deviationToTicks(uint256 deviation) public pure returns (int24) { + // 2% ~ 200 ticks -> (POOL.tickSpacing()) + // NOTE: width can't be zero, we set the minimum possible to 200. + uint256 t = deviation.mulDiv(10000, ONE); + t -= (t % 200); + return (t >= 200 ? SafeCast.toInt24(SafeCast.toInt256(t)) : int24(200)); + } + /// @return Number of decimals representing 1.0. function decimals() external pure returns (uint8) { return uint8(DECIMALS); } + + //----------------------------------------------------------------------------- + // Private methods + + /// @dev Configures the vault to provide concentrated liquidity in the active zone. + function _setupActiveZoneLiq(uint256 deviation) private { + VAULT.setFullRangeWeight( + SafeCast.toUint24(uint256(VAULT_MAX_FRW).mulDiv(activeFullRangePerc(), ONE)) + ); + + // IMPORTANT: + // + // If price is exactly at the bounds of `activeZoneDeviation`, + // the concentrated liquidity will be *at most* + // `deviationToTicks(concBandDeviationWidth/2)` outside the bounds. + // + VAULT.setBaseThreshold(deviationToTicks(concBandDeviationWidth)); + VAULT.setLimitThreshold( + deviationToTicks( + isOverweightSpot() + ? Math.max( + activeZoneDeviation.upper - deviation, + concBandDeviationWidth / 2 + ) + : Math.max( + deviation - activeZoneDeviation.lower, + concBandDeviationWidth / 2 + ) + ) + ); + } + + /// @dev Resets the vault to provide full range liquidity. + function _resetLiq() private { + VAULT.setFullRangeWeight(VAULT_MAX_FRW); + VAULT.setBaseThreshold(POOL_MAX_TICK); + VAULT.setLimitThreshold(POOL_MAX_TICK); + } } diff --git a/spot-vaults/test/UsdcSpotManager.ts b/spot-vaults/test/UsdcSpotManager.ts index 59fa6d0d..3f38c72a 100644 --- a/spot-vaults/test/UsdcSpotManager.ts +++ b/spot-vaults/test/UsdcSpotManager.ts @@ -20,14 +20,29 @@ describe("UsdcSpotManager", function () { const mockPool = new DMock("IUniswapV3Pool"); await mockPool.deploy(); + await mockPool.mockCall( + "positions(bytes32)", + [univ3PositionKey(mockVault.target, -800000, 800000)], + [100000, 0, 0, 0, 0], + ); + await mockPool.mockCall( + "positions(bytes32)", + [univ3PositionKey(mockVault.target, -1000, 1000)], + [0, 0, 0, 0, 0], + ); await mockPool.mockCall( "positions(bytes32)", [univ3PositionKey(mockVault.target, 20000, 40000)], [50000, 0, 0, 0, 0], ); + await mockVault.mockMethod("baseLower()", [-1000]); + await mockVault.mockMethod("baseUpper()", [1000]); + await mockVault.mockMethod("fullLower()", [-800000]); + await mockVault.mockMethod("fullUpper()", [800000]); await mockVault.mockMethod("limitLower()", [20000]); await mockVault.mockMethod("limitUpper()", [40000]); await mockVault.mockMethod("pool()", [mockPool.target]); + await mockVault.mockMethod("getTotalAmounts()", [usdcFP("500000"), spotFP("500000")]); const mockOracle = new DMock("IMetaOracle"); await mockOracle.deploy(); @@ -79,8 +94,24 @@ describe("UsdcSpotManager", function () { await mockVault.mockMethod("getTwap()", [29999]); } - async function stubUnchangedLimitRange(mockVault) { - await mockVault.clearMockMethod("emergencyBurn(int24,int24,uint128)"); + async function stubActiveZoneLiq(mockVault, fr, base, limit) { + await mockVault.mockCall("setFullRangeWeight(uint24)", [fr], []); + await mockVault.mockCall("setBaseThreshold(int24)", [base], []); + await mockVault.mockCall("setLimitThreshold(int24)", [limit], []); + } + + async function stubInactiveLiq(mockVault) { + await mockVault.mockCall("setFullRangeWeight(uint24)", [1000000], []); + await mockVault.mockCall("setBaseThreshold(int24)", [48000], []); + await mockVault.mockCall("setLimitThreshold(int24)", [48000], []); + } + + async function stubTrimFullRangeLiq(mockVault, burntLiq) { + await mockVault.mockCall( + "emergencyBurn(int24,int24,uint128)", + [-800000, 800000, burntLiq], + [], + ); } async function stubRemovedLimitRange(mockVault) { @@ -117,6 +148,16 @@ describe("UsdcSpotManager", function () { const { manager } = await loadFixture(setupContracts); expect(await manager.decimals()).to.eq(18); }); + + it("should set initial parameters", async function () { + const { manager } = await loadFixture(setupContracts); + expect(await manager.prevWithinActiveZone()).to.eq(false); + const r = await manager.activeZoneDeviation(); + expect(r[0]).to.eq(percFP("0.95")); + expect(r[1]).to.eq(percFP("1.05")); + expect(await manager.concBandDeviationWidth()).to.eq(percFP("0.05")); + expect(await manager.fullRangeMaxUsdcBal()).to.eq(usdcFP("250000")); + }); }); describe("#updateOracle", function () { @@ -150,20 +191,58 @@ describe("UsdcSpotManager", function () { }); }); - describe("#setLiquidityRanges", function () { + describe("#updateActiveZone", function () { it("should fail to when called by non-owner", async function () { const { manager, addr1 } = await loadFixture(setupContracts); await expect( - manager.connect(addr1).setLiquidityRanges(7200, 330000, 1200), + manager.connect(addr1).updateActiveZone([percFP("0.9"), percFP("1.1")]), ).to.be.revertedWith("Ownable: caller is not the owner"); }); it("should succeed when called by owner", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await mockVault.mockCall("setBaseThreshold(int24)", [7200], []); - await mockVault.mockCall("setFullRangeWeight(uint24)", [330000], []); - await mockVault.mockCall("setLimitThreshold(int24)", [1200], []); - await manager.setLiquidityRanges(7200, 330000, 1200); + const { manager } = await loadFixture(setupContracts); + await manager.updateActiveZone([percFP("0.9"), percFP("1.1")]); + const r = await manager.activeZoneDeviation(); + expect(r[0]).to.eq(percFP("0.9")); + expect(r[1]).to.eq(percFP("1.1")); + }); + }); + + describe("#updateConcentratedBand", function () { + it("should fail to when called by non-owner", async function () { + const { manager, addr1 } = await loadFixture(setupContracts); + await expect( + manager.connect(addr1).updateConcentratedBand(percFP("0.1")), + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + + it("should succeed when called by owner", async function () { + const { manager } = await loadFixture(setupContracts); + await manager.updateConcentratedBand(percFP("0.1")); + expect(await manager.concBandDeviationWidth()).to.eq(percFP("0.1")); + }); + }); + + describe("#updateFullRangeLiquidity", function () { + it("should fail to when called by non-owner", async function () { + const { manager, addr1 } = await loadFixture(setupContracts); + await expect( + manager.connect(addr1).updateFullRangeLiquidity(usdcFP("1000000"), percFP("0.2")), + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + + it("should fail to when param is invalid", async function () { + const { manager } = await loadFixture(setupContracts); + await expect( + manager.updateFullRangeLiquidity(usdcFP("1000000"), percFP("1.2")), + ).to.be.revertedWith("InvalidPerc"); + }); + + it("should succeed when called by owner", async function () { + const { manager } = await loadFixture(setupContracts); + await manager.updateFullRangeLiquidity(usdcFP("1000000"), percFP("0.2")); + expect(await manager.fullRangeMaxUsdcBal()).to.eq(usdcFP("1000000")); + expect(await manager.fullRangeMaxPerc()).to.eq(percFP("0.2")); }); }); @@ -221,135 +300,195 @@ describe("UsdcSpotManager", function () { }); }); - describe("shouldRemoveLimitRange", function () { - describe("is overweight spot", function () { - it("should return bool", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await stubOverweightSpot(mockVault); - expect(await manager.shouldRemoveLimitRange(percFP("1.01"))).to.eq(false); - expect(await manager.shouldRemoveLimitRange(percFP("1"))).to.eq(false); - expect(await manager.shouldRemoveLimitRange(percFP("0.99"))).to.eq(true); - }); - }); - - describe("is overweight usdc", function () { - it("should return bool", async function () { - const { manager, mockVault } = await loadFixture(setupContracts); - await stubOverweightUsdc(mockVault); - expect(await manager.shouldRemoveLimitRange(percFP("1.01"))).to.eq(true); - expect(await manager.shouldRemoveLimitRange(percFP("1"))).to.eq(false); - expect(await manager.shouldRemoveLimitRange(percFP("0.99"))).to.eq(false); - }); + describe("activeZone", function () { + it("should return state", async function () { + const { manager } = await loadFixture(setupContracts); + expect(await manager.activeZone(percFP("0.949"))).to.eq(false); + expect(await manager.activeZone(percFP("0.95"))).to.eq(true); + expect(await manager.activeZone(percFP("0.975"))).to.eq(true); + expect(await manager.activeZone(percFP("1"))).to.eq(true); + expect(await manager.activeZone(percFP("1.025"))).to.eq(true); + expect(await manager.activeZone(percFP("1.05"))).to.eq(true); + expect(await manager.activeZone(percFP("1.051"))).to.eq(false); }); }); - describe("shouldForceRebalance", function () { - describe("when deviation crosses 1", function () { - it("should return true", async function () { - const { manager } = await loadFixture(setupContracts); - expect(await manager.shouldForceRebalance(percFP("0.9"), percFP("1.1"))).to.eq( - true, - ); - expect(await manager.shouldForceRebalance(percFP("1.5"), percFP("0.99"))).to.eq( - true, - ); - expect(await manager.shouldForceRebalance(percFP("1"), percFP("1.1"))).to.eq( - true, - ); - expect(await manager.shouldForceRebalance(percFP("1"), percFP("0.99"))).to.eq( - true, - ); - }); + describe("activeFullRangePerc", function () { + it("should calculate full range perc", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await manager.updateFullRangeLiquidity(usdcFP("25000"), percFP("0.2")); + await mockVault.mockMethod("getTotalAmounts()", [usdcFP("500000"), spotFP("0")]); + expect(await manager.activeFullRangePerc()).to.eq(percFP("0.05")); + }); + it("should calculate full range perc", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await manager.updateFullRangeLiquidity(usdcFP("250000"), percFP("0.1")); + await mockVault.mockMethod("getTotalAmounts()", [usdcFP("500000"), spotFP("10")]); + expect(await manager.activeFullRangePerc()).to.eq(percFP("0.1")); + }); + it("should calculate full range perc", async function () { + const { manager, mockVault } = await loadFixture(setupContracts); + await manager.updateFullRangeLiquidity(usdcFP("250000"), percFP("1")); + await mockVault.mockMethod("getTotalAmounts()", [ + usdcFP("500000"), + spotFP("10000"), + ]); + expect(await manager.activeFullRangePerc()).to.eq(percFP("0.5")); }); + }); - describe("when deviation does not cross 1", function () { - it("should return false", async function () { - const { manager } = await loadFixture(setupContracts); - expect(await manager.shouldForceRebalance(percFP("0.9"), percFP("0.99"))).to.eq( - false, - ); - expect(await manager.shouldForceRebalance(percFP("1.5"), percFP("1.1"))).to.eq( - false, - ); - expect(await manager.shouldForceRebalance(percFP("0.9"), percFP("1"))).to.eq( - false, - ); - expect(await manager.shouldForceRebalance(percFP("1.5"), percFP("1"))).to.eq( - false, - ); - }); + describe("deviationToTicks", function () { + it("should return ticks", async function () { + const { manager } = await loadFixture(setupContracts); + expect(await manager.deviationToTicks(percFP("0.1"))).to.eq(1000); + expect(await manager.deviationToTicks(percFP("0.05"))).to.eq(400); + expect(await manager.deviationToTicks(percFP("0.025"))).to.eq(200); + expect(await manager.deviationToTicks(percFP("0.01"))).to.eq(200); + expect(await manager.deviationToTicks(percFP("0"))).to.eq(200); }); }); describe("#rebalance", function () { - it("should rebalance, update limit range and prev_deviation", async function () { - const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); - - await stubOverweightUsdc(mockVault); - await stubRebalance(mockVault); - await stubUnchangedLimitRange(mockVault); - await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.99"), true]); - - expect(await manager.isOverweightSpot()).to.eq(false); - expect(await manager.prevDeviation()).to.eq("0"); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.99")); + describe("when price is inside active range, previously outside", function () { + it("should force rebalance and update liquidity", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.98"), true]); + + await stubOverweightUsdc(mockVault); + await stubActiveZoneLiq(mockVault, 500000, 400, 200); + await stubForceRebalance(mockVault); + + expect(await manager.isOverweightSpot()).to.eq(false); + expect(await manager.prevWithinActiveZone()).to.eq(false); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevWithinActiveZone()).to.eq(true); + }); + + it("should force rebalance and update liquidity", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.98"), true]); + + await stubOverweightSpot(mockVault); + await stubActiveZoneLiq(mockVault, 500000, 400, 600); + await stubForceRebalance(mockVault); + + expect(await manager.isOverweightSpot()).to.eq(true); + expect(await manager.prevWithinActiveZone()).to.eq(false); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevWithinActiveZone()).to.eq(true); + }); }); - it("should rebalance, update limit range and prev_deviation", async function () { - const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + describe("when price is outside active range, previously inside", function () { + it("should rebalance and update liquidity", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); - await stubOverweightSpot(mockVault); - await stubRebalance(mockVault); - await stubRemovedLimitRange(mockVault); - await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.99"), true]); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1"), true]); + await stubOverweightUsdc(mockVault); + await stubActiveZoneLiq(mockVault, 500000, 400, 400); + await stubForceRebalance(mockVault); + await manager.rebalance(); + + await mockVault.mockMethod("getTotalAmounts()", [ + usdcFP("1000000"), + spotFP("500000"), + ]); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.5"), true]); + await stubInactiveLiq(mockVault); + await stubTrimFullRangeLiq(mockVault, 75000); + await stubRemovedLimitRange(mockVault); + await stubForceRebalance(mockVault); - expect(await manager.isOverweightSpot()).to.eq(true); - expect(await manager.prevDeviation()).to.eq("0"); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0.99")); + expect(await manager.isOverweightSpot()).to.eq(false); + expect(await manager.prevWithinActiveZone()).to.eq(true); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevWithinActiveZone()).to.eq(false); + }); }); - it("should rebalance, update limit range and prev_deviation", async function () { - const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + describe("when price is inside active range, previously inside", function () { + it("should rebalance and update liquidity", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1"), true]); + await stubOverweightUsdc(mockVault); + await stubActiveZoneLiq(mockVault, 500000, 400, 400); + await stubForceRebalance(mockVault); + await manager.rebalance(); + + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.02"), true]); + await stubActiveZoneLiq(mockVault, 500000, 400, 600); + await stubRebalance(mockVault); + + expect(await manager.isOverweightSpot()).to.eq(false); + expect(await manager.prevWithinActiveZone()).to.eq(true); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevWithinActiveZone()).to.eq(true); + }); + + it("should rebalance and update liquidity", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1"), true]); + await stubOverweightUsdc(mockVault); + await stubActiveZoneLiq(mockVault, 500000, 400, 400); + await stubForceRebalance(mockVault); + await manager.rebalance(); - await stubOverweightUsdc(mockVault); - await stubForceRebalance(mockVault); - await stubRemovedLimitRange(mockVault); - await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.2"), true]); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.02"), true]); + await stubOverweightSpot(mockVault); + await stubActiveZoneLiq(mockVault, 500000, 400, 200); + await stubRebalance(mockVault); - expect(await manager.isOverweightSpot()).to.eq(false); - expect(await manager.prevDeviation()).to.eq("0"); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.2")); + expect(await manager.isOverweightSpot()).to.eq(true); + expect(await manager.prevWithinActiveZone()).to.eq(true); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevWithinActiveZone()).to.eq(true); + }); }); - it("should rebalance, update limit range and prev_deviation", async function () { - const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + describe("when price is outside active range, previously outside", function () { + it("should rebalance and update liquidity", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); - await stubOverweightSpot(mockVault); - await stubForceRebalance(mockVault); - await stubUnchangedLimitRange(mockVault); - await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.2"), true]); + await mockVault.mockMethod("getTotalAmounts()", [ + usdcFP("2500000"), + spotFP("500000"), + ]); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.75"), true]); + await stubOverweightUsdc(mockVault); + await stubInactiveLiq(mockVault); + await stubTrimFullRangeLiq(mockVault, 90000); + await stubRemovedLimitRange(mockVault); + await stubRebalance(mockVault); - expect(await manager.isOverweightSpot()).to.eq(true); - expect(await manager.prevDeviation()).to.eq("0"); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("1.2")); + expect(await manager.isOverweightSpot()).to.eq(false); + expect(await manager.prevWithinActiveZone()).to.eq(false); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevWithinActiveZone()).to.eq(false); + }); }); - it("should rebalance, remove limit range and not change prev_deviation", async function () { - const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); + describe("when price is invalid", function () { + it("should rebalance and update liquidity", async function () { + const { manager, mockVault, mockOracle } = await loadFixture(setupContracts); - await stubOverweightSpot(mockVault); - await stubRebalance(mockVault); - await stubRemovedLimitRange(mockVault); - await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("1.2"), false]); + await mockVault.mockMethod("getTotalAmounts()", [ + usdcFP("2500000"), + spotFP("500000"), + ]); + await mockOracle.mockMethod("spotPriceDeviation()", [priceFP("0.75"), false]); + await stubOverweightUsdc(mockVault); + await stubInactiveLiq(mockVault); + await stubTrimFullRangeLiq(mockVault, 90000); + await stubRemovedLimitRange(mockVault); + await stubRebalance(mockVault); - expect(await manager.isOverweightSpot()).to.eq(true); - expect(await manager.prevDeviation()).to.eq("0"); - await expect(manager.rebalance()).not.to.be.reverted; - expect(await manager.prevDeviation()).to.eq(percFP("0")); + expect(await manager.isOverweightSpot()).to.eq(false); + expect(await manager.prevWithinActiveZone()).to.eq(false); + await expect(manager.rebalance()).not.to.be.reverted; + expect(await manager.prevWithinActiveZone()).to.eq(false); + }); }); }); });