harb/onchain/script/backtesting/BaselineStrategies.sol

230 lines
9.8 KiB
Solidity
Raw Normal View History

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol";
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
// ─────────────────────────────────────────────────────────────────────────────
// Full-Range LP Strategy
// ─────────────────────────────────────────────────────────────────────────────
/// @title FullRangeLPStrategy
/// @notice Single LP position covering the entire valid tick range (MIN_TICK to MAX_TICK).
/// Position is set once at initialization and never rebalanced, giving 100% time-in-range.
contract FullRangeLPStrategy is IUniswapV3MintCallback {
/// @dev Tick boundaries aligned to 200-spacing for the 1% fee pool
int24 public constant TICK_LOWER = -887_200;
int24 public constant TICK_UPPER = 887_200;
IUniswapV3Pool public immutable pool;
IERC20 public immutable token0;
IERC20 public immutable token1;
bool public immutable token0isWeth;
uint128 public liquidity;
uint256 public feesCollected0;
uint256 public feesCollected1;
uint256 public totalBlocks;
bool public initialized;
constructor(IUniswapV3Pool _pool, IERC20 _weth, IERC20 _token, bool _token0isWeth) {
pool = _pool;
token0isWeth = _token0isWeth;
token0 = _token0isWeth ? _weth : _token;
token1 = _token0isWeth ? _token : _weth;
}
/// @notice Initialize: use current token balances to mint a full-range position.
/// Caller must transfer tokens to this contract before calling.
function initialize() external {
require(!initialized, "already initialized");
initialized = true;
uint256 bal0 = token0.balanceOf(address(this));
uint256 bal1 = token1.balanceOf(address(this));
(, int24 currentTick,,,,,) = pool.slot0();
uint160 sqrtCurrent = TickMath.getSqrtRatioAtTick(currentTick);
uint160 sqrtLower = TickMath.getSqrtRatioAtTick(TICK_LOWER);
uint160 sqrtUpper = TickMath.getSqrtRatioAtTick(TICK_UPPER);
liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtCurrent, sqrtLower, sqrtUpper, bal0, bal1);
if (liquidity > 0) {
pool.mint(address(this), TICK_LOWER, TICK_UPPER, liquidity, "");
}
}
/// @notice Record one simulation block. Full-range is always in-range, so this just increments totalBlocks.
function recordBlock() external {
totalBlocks++;
}
/// @notice Collect accrued fees without changing liquidity.
function collectFees() external {
if (liquidity > 0) {
pool.burn(TICK_LOWER, TICK_UPPER, 0);
}
(uint256 f0, uint256 f1) = pool.collect(address(this), TICK_LOWER, TICK_UPPER, type(uint128).max, type(uint128).max);
feesCollected0 += f0;
feesCollected1 += f1;
}
/// @notice Burn entire position and collect all tokens + fees.
function finalize() external {
if (liquidity > 0) {
pool.burn(TICK_LOWER, TICK_UPPER, liquidity);
(uint256 f0, uint256 f1) = pool.collect(address(this), TICK_LOWER, TICK_UPPER, type(uint128).max, type(uint128).max);
feesCollected0 += f0;
feesCollected1 += f1;
liquidity = 0;
}
}
function getWethBalance() external view returns (uint256) {
return token0isWeth ? token0.balanceOf(address(this)) : token1.balanceOf(address(this));
}
function getTokenBalance() external view returns (uint256) {
return token0isWeth ? token1.balanceOf(address(this)) : token0.balanceOf(address(this));
}
/// @inheritdoc IUniswapV3MintCallback
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
require(msg.sender == address(pool), "not pool");
if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed);
if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Fixed-Width LP Strategy
// ─────────────────────────────────────────────────────────────────────────────
/// @title FixedWidthLPStrategy
/// @notice LP position ±2000 ticks around current price (200-tick spacing).
/// Rebalances (removes + remints) when price exits the range.
contract FixedWidthLPStrategy is IUniswapV3MintCallback {
int24 public constant HALF_WIDTH = 2_000; // ±2000 ticks from center
int24 public constant TICK_SPACING = 200;
int24 public constant MIN_TICK = -887_200;
int24 public constant MAX_TICK = 887_200;
IUniswapV3Pool public immutable pool;
IERC20 public immutable token0;
IERC20 public immutable token1;
bool public immutable token0isWeth;
int24 public tickLower;
int24 public tickUpper;
uint128 public liquidity;
uint256 public rebalanceCount;
uint256 public feesCollected0;
uint256 public feesCollected1;
uint256 public blocksInRange;
uint256 public totalBlocks;
bool public initialized;
constructor(IUniswapV3Pool _pool, IERC20 _weth, IERC20 _token, bool _token0isWeth) {
pool = _pool;
token0isWeth = _token0isWeth;
token0 = _token0isWeth ? _weth : _token;
token1 = _token0isWeth ? _token : _weth;
}
/// @notice Initialize around the current pool price.
/// Caller must transfer tokens to this contract before calling.
function initialize() external {
require(!initialized, "already initialized");
initialized = true;
(, int24 currentTick,,,,,) = pool.slot0();
_createPosition(currentTick);
}
/// @notice Record one simulation block; tracks time-in-range.
function recordBlock() external {
totalBlocks++;
(, int24 currentTick,,,,,) = pool.slot0();
if (currentTick >= tickLower && currentTick < tickUpper) {
blocksInRange++;
}
}
/// @notice Rebalance if current price is outside the position range.
/// @return didRebalance true if a rebalance occurred.
function maybeRebalance() external returns (bool didRebalance) {
(, int24 currentTick,,,,,) = pool.slot0();
if (currentTick >= tickLower && currentTick < tickUpper) return false;
_removePosition();
_createPosition(currentTick);
rebalanceCount++;
return true;
}
/// @notice Collect accrued fees without changing liquidity.
function collectFees() external {
if (liquidity > 0) {
pool.burn(tickLower, tickUpper, 0);
}
(uint256 f0, uint256 f1) = pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max);
feesCollected0 += f0;
feesCollected1 += f1;
}
/// @notice Burn current position and collect everything.
function finalize() external {
_removePosition();
}
function getWethBalance() external view returns (uint256) {
return token0isWeth ? token0.balanceOf(address(this)) : token1.balanceOf(address(this));
}
function getTokenBalance() external view returns (uint256) {
return token0isWeth ? token1.balanceOf(address(this)) : token0.balanceOf(address(this));
}
// ── Internal ──────────────────────────────────────────────────────────────
function _createPosition(int24 currentTick) internal {
int24 center = (currentTick / TICK_SPACING) * TICK_SPACING;
tickLower = center - HALF_WIDTH;
tickUpper = center + HALF_WIDTH;
// Clamp to pool boundaries
if (tickLower < MIN_TICK) tickLower = ((MIN_TICK / TICK_SPACING) + 1) * TICK_SPACING;
if (tickUpper > MAX_TICK) tickUpper = (MAX_TICK / TICK_SPACING) * TICK_SPACING;
uint256 bal0 = token0.balanceOf(address(this));
uint256 bal1 = token1.balanceOf(address(this));
uint160 sqrtCurrent = TickMath.getSqrtRatioAtTick(currentTick);
uint160 sqrtLow = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtHigh = TickMath.getSqrtRatioAtTick(tickUpper);
liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtCurrent, sqrtLow, sqrtHigh, bal0, bal1);
if (liquidity > 0) {
pool.mint(address(this), tickLower, tickUpper, liquidity, "");
}
}
function _removePosition() internal {
if (liquidity > 0) {
pool.burn(tickLower, tickUpper, liquidity);
(uint256 f0, uint256 f1) = pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max);
feesCollected0 += f0;
feesCollected1 += f1;
liquidity = 0;
}
}
/// @inheritdoc IUniswapV3MintCallback
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override {
require(msg.sender == address(pool), "not pool");
if (amount0Owed > 0) token0.transfer(msg.sender, amount0Owed);
if (amount1Owed > 0) token1.transfer(msg.sender, amount1Owed);
}
}