Skip to content

Commit 6a626c2

Browse files
committed
updated limit band handling
1 parent feaf76b commit 6a626c2

File tree

2 files changed

+125
-31
lines changed

2 files changed

+125
-31
lines changed

spot-vaults/contracts/charm/UsdcSpotManager.sol

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@ import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3
1313

1414
/// @title UsdcSpotManager
1515
/// @notice This contract is a programmatic manager for the USDC-SPOT Charm AlphaProVault.
16-
/// @dev The vault provides highly concentrated liquidity when the market-price of SPOT is within
17-
/// the "active" zone as defined by the owner. When price moves outside the
18-
/// zone, the vault moves all assets into a full range position (infinite band).
16+
///
17+
/// @dev The vault's active zone is defined as lower and upper percentages of FMV.
18+
/// For example, if the active zone is [0.95, 1.05]x and SPOT's FMV price is $1.35.
19+
/// When the market price of SPOT is between [$1.28, $1.41] we consider price to be in the active zone.
20+
///
21+
/// When in the active zone, the vault provides concentrated liquidity around the market price.
22+
/// When price is outside the active zone, the vault reverts to a full range position.
23+
///
24+
/// The vault further minimizes IL from charm's passive rebalancing,
25+
/// by limiting buying SPOT above it's FMV or selling SPOT below it's FMV.
26+
///
1927
contract UsdcSpotManager is Ownable {
2028
//-------------------------------------------------------------------------
2129
// Libraries
@@ -59,6 +67,9 @@ contract UsdcSpotManager is Ownable {
5967
/// @notice The maximum USDC balance of the vault's full range position.
6068
uint256 public fullRangeMaxUsdcBal;
6169

70+
/// @notice The maximum percentage of vault's balanced assets in the full range position.
71+
uint256 public fullRangeMaxPerc;
72+
6273
//-----------------------------------------------------------------------------
6374
// Constructor and Initializer
6475

@@ -76,8 +87,9 @@ contract UsdcSpotManager is Ownable {
7687
lower: ((ONE * 95) / 100), // 0.95 or 95%
7788
upper: ((ONE * 105) / 100) // 1.05 or 105%
7889
});
79-
concBandDeviationWidth = ((ONE * 5) / 100); // 0.05 or 5%
90+
concBandDeviationWidth = (ONE / 20); // 0.05 or 5%
8091
fullRangeMaxUsdcBal = 250000 * (10 ** 6); // 250k USDC
92+
fullRangeMaxPerc = (ONE / 2); // 0.5 or 50%
8193
}
8294

8395
//--------------------------------------------------------------------------
@@ -113,9 +125,16 @@ contract UsdcSpotManager is Ownable {
113125
concBandDeviationWidth = concBandDeviationWidth_;
114126
}
115127

116-
/// @notice Updates the amount of liquidity in the full range liquidity band.
117-
function updateFullRangeLiquidity(uint256 fullRangeMaxUsdcBal_) external onlyOwner {
128+
/// @notice Updates the absolute and percentage maximum amount of liquidity
129+
/// in the full range liquidity band.
130+
function updateFullRangeLiquidity(
131+
uint256 fullRangeMaxUsdcBal_,
132+
uint256 fullRangeMaxPerc_
133+
) external onlyOwner {
134+
// solhint-disable-next-line custom-errors
135+
require(fullRangeMaxPerc_ <= ONE, "InvalidPerc");
118136
fullRangeMaxUsdcBal = fullRangeMaxUsdcBal_;
137+
fullRangeMaxPerc = fullRangeMaxPerc_;
119138
}
120139

121140
//--------------------------------------------------------------------------
@@ -127,7 +146,6 @@ contract UsdcSpotManager is Ownable {
127146
bool withinActiveZone = (deviationValid && activeZone(deviation));
128147
bool withinActiveZoneBefore = activeZone(prevDeviation);
129148
bool shouldForceRebalance = (withinActiveZone != withinActiveZoneBefore);
130-
(withinActiveZone && !withinActiveZoneBefore));
131149

132150
// Set liquidity parameters.
133151
withinActiveZone ? _setupActiveZoneLiq(deviation) : _resetLiq();
@@ -139,7 +157,7 @@ contract UsdcSpotManager is Ownable {
139157
shouldForceRebalance ? VAULT.forceRebalance() : VAULT.rebalance();
140158

141159
// Trim positions after rebalance.
142-
_trimLiquidity(withinActiveZone);
160+
_trimLiquidity(deviation, withinActiveZone);
143161

144162
// Update valid rebalance state.
145163
if (deviationValid) {
@@ -148,10 +166,10 @@ contract UsdcSpotManager is Ownable {
148166
}
149167

150168
//-----------------------------------------------------------------------------
151-
// External/Public view methods
169+
// External/Public read methods
152170

153171
/// @notice Based on the given deviation factor,
154-
/// calculates if the pool is in the active zone.
172+
/// calculates if the pool needs to be in the active zone.
155173
function activeZone(uint256 deviation) public view returns (bool) {
156174
Range memory concBandDeviation = Range({
157175
lower: (activeZoneDeviation.lower + concBandDeviationWidth / 2),
@@ -161,6 +179,23 @@ contract UsdcSpotManager is Ownable {
161179
deviation <= concBandDeviation.upper);
162180
}
163181

182+
/// @notice Computes the percentage of liquidity to be deployed into the full range,
183+
/// based on owner defined maximums.
184+
function activeFullRangePerc() public view returns (uint256) {
185+
(uint256 usdcBal, ) = VAULT.getTotalAmounts();
186+
return Math.min(ONE.mulDiv(fullRangeMaxUsdcBal, usdcBal), fullRangeMaxPerc);
187+
}
188+
189+
/// @notice Checks if limit range liquidity needs to be removed.
190+
function activeLimitRange(uint256 deviation) public view returns (bool) {
191+
// We only activate the limit range liquidity, when
192+
// the vault sells SPOT and deviation is above (ONE-concBandDeviationWidth/2), or when
193+
// the vault buys SPOT and deviation is below (ONE+concBandDeviationWidth/2)
194+
bool extraSpot = isOverweightSpot();
195+
return ((extraSpot && deviation >= (ONE - concBandDeviationWidth / 2)) ||
196+
(!extraSpot && deviation <= (ONE + concBandDeviationWidth / 2)));
197+
}
198+
164199
/// @notice Checks the vault is overweight SPOT and looking to sell the extra SPOT for USDC.
165200
function isOverweightSpot() public view returns (bool) {
166201
// NOTE: In the underlying univ3 pool and token0 is USDC and token1 is SPOT.
@@ -182,22 +217,24 @@ contract UsdcSpotManager is Ownable {
182217
return uint8(DECIMALS);
183218
}
184219

220+
//-----------------------------------------------------------------------------
221+
// Private methods
222+
185223
/// @dev Configures the vault to provide concentrated liquidity in the active zone.
186224
function _setupActiveZoneLiq(uint256 deviation) private {
187-
(uint256 usdcBal, ) = VAULT.getTotalAmounts();
188-
uint256 fullRangeWeight = uint256(VAULT_MAX_FRW).mulDiv(
189-
fullRangeMaxUsdcBal,
190-
usdcBal
225+
VAULT.setFullRangeWeight(
226+
SafeCast.toUint24(uint256(VAULT_MAX_FRW).mulDiv(activeFullRangePerc(), ONE))
191227
);
192-
uint256 limitBandDeviationWidth = isOverweightSpot()
193-
? activeZoneDeviation.upper - deviation
194-
: deviation - activeZoneDeviation.lower;
195-
VAULT.setFullRangeWeight(SafeCast.toUint24(fullRangeWeight));
196-
197228
// IMPORTANT: The deviation percentage, to ticks conversion isn't precise.
198229
// In the worst case, there might be liquidity up to 2% outside the active zone definition.
199230
VAULT.setBaseThreshold(deviationToTicks(concBandDeviationWidth));
200-
VAULT.setLimitThreshold(deviationToTicks(limitBandDeviationWidth));
231+
VAULT.setLimitThreshold(
232+
deviationToTicks(
233+
isOverweightSpot()
234+
? activeZoneDeviation.upper - deviation
235+
: deviation - activeZoneDeviation.lower
236+
)
237+
);
201238
}
202239

203240
/// @dev Resets the vault to provide full range liquidity.
@@ -208,15 +245,12 @@ contract UsdcSpotManager is Ownable {
208245
}
209246

210247
/// @dev Removes excess liquidity from the vault after rebalance.
211-
function _trimLiquidity(bool withinActiveZone) private {
212-
// If we are in the active zone, we keep all the liquidity.
213-
if (withinActiveZone) {
214-
return;
248+
function _trimLiquidity(uint256 deviation, bool withinActiveZone) private {
249+
if (!withinActiveZone) {
250+
VAULT.trimLiquidity(POOL, ONE - activeFullRangePerc(), ONE);
251+
}
252+
if (!activeLimitRange(deviation)) {
253+
VAULT.removeLimitLiquidity(POOL);
215254
}
216-
// Otherwise, we trim positions.
217-
(uint256 usdcBal, ) = VAULT.getTotalAmounts();
218-
uint256 percToRemove = (ONE - ONE.mulDiv(fullRangeMaxUsdcBal, usdcBal));
219-
VAULT.trimLiquidity(POOL, percToRemove, ONE);
220-
VAULT.removeLimitLiquidity(POOL);
221255
}
222256
}

spot-vaults/test/UsdcSpotManager.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,14 +227,22 @@ describe("UsdcSpotManager", function () {
227227
it("should fail to when called by non-owner", async function () {
228228
const { manager, addr1 } = await loadFixture(setupContracts);
229229
await expect(
230-
manager.connect(addr1).updateFullRangeLiquidity(usdcFP("1000000")),
230+
manager.connect(addr1).updateFullRangeLiquidity(usdcFP("1000000"), percFP("0.2")),
231231
).to.be.revertedWith("Ownable: caller is not the owner");
232232
});
233233

234+
it("should fail to when param is invalid", async function () {
235+
const { manager } = await loadFixture(setupContracts);
236+
await expect(
237+
manager.updateFullRangeLiquidity(usdcFP("1000000"), percFP("1.2")),
238+
).to.be.revertedWith("InvalidPerc");
239+
});
240+
234241
it("should succeed when called by owner", async function () {
235242
const { manager } = await loadFixture(setupContracts);
236-
await manager.updateFullRangeLiquidity(usdcFP("1000000"));
243+
await manager.updateFullRangeLiquidity(usdcFP("1000000"), percFP("0.2"));
237244
expect(await manager.fullRangeMaxUsdcBal()).to.eq(usdcFP("1000000"));
245+
expect(await manager.fullRangeMaxPerc()).to.eq(percFP("0.2"));
238246
});
239247
});
240248

@@ -303,6 +311,58 @@ describe("UsdcSpotManager", function () {
303311
});
304312
});
305313

314+
describe("activeFullRangePerc", function () {
315+
it("should calculate full range perc", async function () {
316+
const { manager, mockVault } = await loadFixture(setupContracts);
317+
await manager.updateFullRangeLiquidity(usdcFP("25000"), percFP("0.2"));
318+
await mockVault.mockMethod("getTotalAmounts()", [usdcFP("500000"), spotFP("0")]);
319+
expect(await manager.activeFullRangePerc()).to.eq(percFP("0.05"));
320+
});
321+
it("should calculate full range perc", async function () {
322+
const { manager, mockVault } = await loadFixture(setupContracts);
323+
await manager.updateFullRangeLiquidity(usdcFP("250000"), percFP("0.1"));
324+
await mockVault.mockMethod("getTotalAmounts()", [usdcFP("500000"), spotFP("10")]);
325+
expect(await manager.activeFullRangePerc()).to.eq(percFP("0.1"));
326+
});
327+
it("should calculate full range perc", async function () {
328+
const { manager, mockVault } = await loadFixture(setupContracts);
329+
await manager.updateFullRangeLiquidity(usdcFP("250000"), percFP("1"));
330+
await mockVault.mockMethod("getTotalAmounts()", [
331+
usdcFP("500000"),
332+
spotFP("10000"),
333+
]);
334+
expect(await manager.activeFullRangePerc()).to.eq(percFP("0.5"));
335+
});
336+
});
337+
338+
describe("activeLimitRange", function () {
339+
describe("is overweight spot", function () {
340+
it("should return bool", async function () {
341+
const { manager, mockVault } = await loadFixture(setupContracts);
342+
await stubOverweightSpot(mockVault);
343+
expect(await manager.activeLimitRange(percFP("1.1"))).to.eq(true);
344+
expect(await manager.activeLimitRange(percFP("1.025"))).to.eq(true);
345+
expect(await manager.activeLimitRange(percFP("1"))).to.eq(true);
346+
expect(await manager.activeLimitRange(percFP("0.975"))).to.eq(true);
347+
expect(await manager.activeLimitRange(percFP("0.974"))).to.eq(false);
348+
expect(await manager.activeLimitRange(percFP("0.9"))).to.eq(false);
349+
});
350+
});
351+
352+
describe("is overweight usdc", function () {
353+
it("should return bool", async function () {
354+
const { manager, mockVault } = await loadFixture(setupContracts);
355+
await stubOverweightUsdc(mockVault);
356+
expect(await manager.activeLimitRange(percFP("1.1"))).to.eq(false);
357+
expect(await manager.activeLimitRange(percFP("1.026"))).to.eq(false);
358+
expect(await manager.activeLimitRange(percFP("1.025"))).to.eq(true);
359+
expect(await manager.activeLimitRange(percFP("1"))).to.eq(true);
360+
expect(await manager.activeLimitRange(percFP("0.975"))).to.eq(true);
361+
expect(await manager.activeLimitRange(percFP("0.9"))).to.eq(true);
362+
});
363+
});
364+
});
365+
306366
describe("deviationToTicks", function () {
307367
it("should return ticks", async function () {
308368
const { manager } = await loadFixture(setupContracts);

0 commit comments

Comments
 (0)