Gamma Strategies' own research repository uses Bollinger Bands to calculate LP range bounds. The insight: a Bollinger Band is a statistical measure of how wide a price range needs to be to capture most of the volatility — which is exactly what you want for a Uniswap v3 LP position.
The problem is that Gamma's implementation runs off-chain. The range computation happens in Python, then gets submitted to the Hypervisor contract via rebalance().
This tutorial shows how to move that decision fully on-chain using Bollinger Band feeds, so a keeper contract can rebalance autonomously without any off-chain computation.
How Gamma positions work
A Gamma Hypervisor holds two simultaneous Uniswap v3 positions:
-
Base position (
baseLowertobaseUpper) — straddles the current price, holds both tokens -
Limit position (
limitLowertolimitUpper) — one-sided, holds surplus of one token
A rebalance() call burns both positions, sets new tick bounds, and re-mints liquidity. The key function:
function rebalance(
int24 _baseLower, int24 _baseUpper,
int24 _limitLower, int24 _limitUpper,
address _feeRecipient,
uint256[4] memory inMin,
uint256[4] memory outMin
) external onlyOwner
From Bollinger Bands to Uniswap ticks
Uniswap v3 positions are defined in ticks, where price = 1.0001^tick. Uniswap provides TickMath.getTickAtSqrtRatio(uint160 sqrtPriceX96) for this conversion — there's no direct price→tick helper. You compute sqrtPriceX96 first:
sqrtPriceX96 = sqrt(rawPrice) × 2^96 / 10000
...where rawPrice is the Pythia/Chainlink feed value scaled 1e8. Bollinger Bands give you upper and lower price bounds — convert each to sqrtPriceX96, call TickMath.getTickAtSqrtRatio(), round to tick spacing, and you have your LP range.
Note on token ordering: Uniswap v3 prices are always token1/token0 sorted by address. Verify your pool's ordering — if inverted, use the reciprocal price.
The interface
interface IPythiaFeed {
function latestAnswer() external view returns (int256);
}
interface IHypervisor {
function rebalance(
int24 _baseLower, int24 _baseUpper,
int24 _limitLower, int24 _limitUpper,
address _feeRecipient,
uint256[4] memory inMin,
uint256[4] memory outMin
) external;
function baseLower() external view returns (int24);
function baseUpper() external view returns (int24);
}
The keeper contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "@uniswap/v3-core/contracts/libraries/TickMath.sol";
contract BollingerKeeperGamma {
IPythiaFeed public immutable bollingerUpper;
IPythiaFeed public immutable bollingerLower;
IPythiaFeed public immutable spotPrice;
IHypervisor public immutable hypervisor;
address public owner;
int24 public tickSpacing;
int24 public limitBuffer = 500;
uint256 public rebalanceThresholdBps = 500; // 5%
constructor(
address _bollingerUpper, address _bollingerLower,
address _spotPrice, address _hypervisor, int24 _tickSpacing
) {
bollingerUpper = IPythiaFeed(_bollingerUpper);
bollingerLower = IPythiaFeed(_bollingerLower);
spotPrice = IPythiaFeed(_spotPrice);
hypervisor = IHypervisor(_hypervisor);
tickSpacing = _tickSpacing;
owner = msg.sender;
}
/// @notice Calculate new tick ranges from current Bollinger Bands.
function calculateRanges()
public view
returns (int24 baseLower, int24 baseUpper, int24 limitLower, int24 limitUpper)
{
int256 upper = bollingerUpper.latestAnswer();
int256 lower = bollingerLower.latestAnswer();
int256 spot = spotPrice.latestAnswer();
baseLower = (priceToTick(lower) / tickSpacing) * tickSpacing;
baseUpper = ((priceToTick(upper) / tickSpacing) + 1) * tickSpacing;
if (spot >= (upper + lower) / 2) {
limitLower = baseUpper;
limitUpper = baseUpper + limitBuffer * tickSpacing;
} else {
limitLower = baseLower - limitBuffer * tickSpacing;
limitUpper = baseLower;
}
}
/// @notice Returns true if the current range has drifted beyond threshold.
function shouldRebalance() public view returns (bool) {
(, int24 newBaseUpper,,) = calculateRanges();
int24 currentUpper = hypervisor.baseUpper();
int24 drift = newBaseUpper > currentUpper
? newBaseUpper - currentUpper
: currentUpper - newBaseUpper;
return uint256(uint24(drift)) * 10000 /
uint256(uint24(currentUpper > 0 ? currentUpper : int24(1)))
> rebalanceThresholdBps;
}
/// @notice Execute rebalance. Callable by keeper when shouldRebalance() is true.
function rebalance() external {
require(msg.sender == owner || shouldRebalance(), "not needed");
(int24 bL, int24 bU, int24 lL, int24 lU) = calculateRanges();
uint256[4] memory zeros;
hypervisor.rebalance(bL, bU, lL, lU, owner, zeros, zeros);
}
/// @dev Uses Uniswap TickMath for accurate price→tick conversion.
/// sqrtPriceX96 = sqrt(rawPrice) * 2^96 / 10000 (for 1e8-scaled prices)
function priceToTick(int256 price) public view returns (int24 tick) {
require(price > 0, "invalid price");
uint256 sqrtP = _sqrt(uint256(price));
uint256 sqrtPriceX96 = (sqrtP * (2 ** 96)) / 10000;
tick = TickMath.getTickAtSqrtRatio(uint160(sqrtPriceX96));
tick = (tick / tickSpacing) * tickSpacing;
}
function _sqrt(uint256 x) internal pure returns (uint256 y) {
if (x == 0) return 0;
uint256 z = (x + 1) / 2;
y = x;
while (z < y) { y = z; z = (x / z + z) / 2; }
}
}
Live Bollinger Bands right now
BTC Bollinger Bands (1D) are currently extremely tight: $67,335 to $67,790 — a $455 range. That's a Band squeeze, which typically precedes a breakout. A keeper running this contract would have set a narrow base position and widened the limit buffer to catch the move.
# pip install pythia-oracle-mcp
from pythia_mcp import PythiaClient
client = PythiaClient()
upper = client.get_feed("bitcoin_BOLLINGER_1D_UPPER")
lower = client.get_feed("bitcoin_BOLLINGER_1D_LOWER")
print(f"BTC Bollinger 1D: ${lower:,.0f} — ${upper:,.0f}") # → $67,335 — $67,790
Automating the keeper
Wire rebalance() to Chainlink Automation or Gelato with shouldRebalance() as the trigger condition. The keeper checks the Bollinger Band width against the current position and rebalances only when the range has drifted enough to matter. No off-chain Python, no subgraph queries.
What else is available
All feeds — EMA, RSI, VWAP, Bollinger Bands, volatility, liquidity — are available on-chain via Pythia as standard Chainlink-compatible feeds. 22 tokens, 4 timeframes, Polygon mainnet. Free testnet faucet, no signup.
Top comments (0)