// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; import { FormatLib } from "./FormatLib.sol"; import { MockToken } from "./MockToken.sol"; import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol"; import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol"; import { Math } from "@openzeppelin/utils/math/Math.sol"; import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import { IUniswapV3MintCallback } from "@uniswap-v3-core/interfaces/callback/IUniswapV3MintCallback.sol"; import { console2 } from "forge-std/console2.sol"; /** * @title BaselineStrategies * @notice Manages three comparison strategies run alongside KrAIken during event replay: * * HODL — Hold initial token amounts, no LP. Zero fees, zero IL by definition. * Full-Range — Single position from aligned MIN_TICK to MAX_TICK. Set once, never rebalanced. * Always 100% time-in-range. * Fixed-Width — ±FIXED_WIDTH_TICKS around current price. Rebalanced at the same block-interval * as KrAIken when the active tick leaves the range. * * All strategies are seeded with the same `initialCapital` (in token0 units) split 50/50 by * value between token0 and token1. HODL holds those amounts; LP strategies deploy them. * * Capital tracking: * The contract implements IUniswapV3MintCallback and freely mints MockTokens as needed — * MockToken.mint() has no access control. Actual entry amounts are recorded from the return * values of pool.mint() for accurate IL computation. * * WARNING — backtesting use only. No access control. */ contract BaselineStrategies is IUniswapV3MintCallback { using Math for uint256; using FormatLib for uint256; using FormatLib for int256; // ------------------------------------------------------------------------- // Types // ------------------------------------------------------------------------- /// @notice Packed result for a single strategy, consumed by Reporter. struct StrategyResult { uint256 initialCapitalToken0; uint256 finalValueToken0; uint256 feesToken0; uint256 rebalances; int256 ilToken0; int256 netPnLToken0; uint256 blocksInRange; uint256 totalBlocks; } // ------------------------------------------------------------------------- // Constants // ------------------------------------------------------------------------- uint256 internal constant Q96 = 2 ** 96; /// @notice Half-width of the fixed-width LP range in ticks (total width = 2×). int24 public constant FIXED_WIDTH_TICKS = 2000; // ------------------------------------------------------------------------- // Immutables // ------------------------------------------------------------------------- IUniswapV3Pool public immutable pool; MockToken public immutable token0; MockToken public immutable token1; bool public immutable token0isWeth; uint256 public immutable recenterInterval; int24 public immutable tickSpacing; // ------------------------------------------------------------------------- // HODL state // ------------------------------------------------------------------------- uint256 public hodlEntryToken0; uint256 public hodlEntryToken1; uint256 public hodlInitialValueToken0; // ------------------------------------------------------------------------- // Full-Range LP state // ------------------------------------------------------------------------- uint128 public fullRangeLiquidity; int24 public frLo; int24 public frHi; uint256 public frEntryToken0; uint256 public frEntryToken1; uint256 public frInitialValueToken0; uint256 public frFees0; uint256 public frFees1; uint256 public frTotalBlocks; uint256 public frBlocksInRange; // ------------------------------------------------------------------------- // Fixed-Width LP state // ------------------------------------------------------------------------- uint128 public fwLiquidity; int24 public fwLo; int24 public fwHi; /// @notice Entry amounts for the CURRENT open period (reset on each rebalance). uint256 public fwPeriodEntryToken0; uint256 public fwPeriodEntryToken1; /// @notice Original entry amounts at the very first deployment (for overall IL reference). uint256 public fwInitialToken0; uint256 public fwInitialToken1; uint256 public fwInitialValueToken0; uint256 public fwFees0; uint256 public fwFees1; uint256 public fwTotalFeesToken0; uint256 public fwRebalances; /// @notice Cumulative IL from closed periods (token0 units, negative = LP underperformed HODL). int256 public fwCumulativeIL; uint256 public fwTotalBlocks; uint256 public fwBlocksInRange; uint256 public fwLastCheckBlock; // ------------------------------------------------------------------------- // Common state // ------------------------------------------------------------------------- bool public initialized; uint256 public lastUpdateBlock; // ------------------------------------------------------------------------- // Constructor // ------------------------------------------------------------------------- constructor(IUniswapV3Pool _pool, MockToken _token0, MockToken _token1, bool _token0isWeth, uint256 _recenterInterval) { pool = _pool; token0 = _token0; token1 = _token1; token0isWeth = _token0isWeth; recenterInterval = _recenterInterval; tickSpacing = _pool.tickSpacing(); } // ------------------------------------------------------------------------- // Initialization // ------------------------------------------------------------------------- /** * @notice Deploy all three baseline strategies with `initialCapital` token0 equivalent. * Capital is split 50/50 by value between token0 and token1. * HODL holds the split; LP strategies deploy it as liquidity. */ function initialize(uint256 initialCapital) external { require(!initialized, "BaselineStrategies: already initialized"); initialized = true; (uint160 sqrtPriceX96, int24 currentTick,,,,,) = pool.slot0(); uint256 half0 = initialCapital / 2; uint256 half1 = _valueInToken1(half0, sqrtPriceX96); // ----- HODL ----- hodlEntryToken0 = half0; hodlEntryToken1 = half1; hodlInitialValueToken0 = _valueInToken0(half0, half1, sqrtPriceX96); // ----- Full-Range LP ----- { int24 lo = _fullRangeLo(); int24 hi = _fullRangeHi(); uint128 liq = LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1 ); if (liq > 0) { (uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, ""); fullRangeLiquidity = liq; frLo = lo; frHi = hi; frEntryToken0 = used0; frEntryToken1 = used1; frInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96); } } // ----- Fixed-Width LP ----- { (int24 lo, int24 hi) = _computeFixedRange(currentTick); uint128 liq = LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, TickMath.getSqrtRatioAtTick(lo), TickMath.getSqrtRatioAtTick(hi), half0, half1 ); if (liq > 0) { (uint256 used0, uint256 used1) = pool.mint(address(this), lo, hi, liq, ""); fwLiquidity = liq; fwLo = lo; fwHi = hi; fwPeriodEntryToken0 = used0; fwPeriodEntryToken1 = used1; fwInitialToken0 = used0; fwInitialToken1 = used1; fwInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96); } fwLastCheckBlock = block.number; } console2.log("=== Baseline Strategies Initialized ==="); console2.log( string.concat( "HODL: entry=[", hodlEntryToken0.str(), ",", hodlEntryToken1.str(), "] initialValue=", hodlInitialValueToken0.str() ) ); console2.log( string.concat( "FullRange: liq=", uint256(fullRangeLiquidity).str(), " ticks=[", int256(frLo).istr(), ",", int256(frHi).istr(), "] entry=[", frEntryToken0.str(), ",", frEntryToken1.str(), "]" ) ); console2.log( string.concat( "FixedWidth: liq=", uint256(fwLiquidity).str(), " ticks=[", int256(fwLo).istr(), ",", int256(fwHi).istr(), "] entry=[", fwPeriodEntryToken0.str(), ",", fwPeriodEntryToken1.str(), "]" ) ); } // ------------------------------------------------------------------------- // Per-block update (called by EventReplayer after each block advance) // ------------------------------------------------------------------------- /** * @notice Update time-in-range counters and trigger FixedWidth rebalancing when needed. * @param blockNum Current block number as advanced by EventReplayer. */ function maybeUpdate(uint256 blockNum) external { if (!initialized) return; if (blockNum == lastUpdateBlock) return; lastUpdateBlock = blockNum; (, int24 currentTick,,,,,) = pool.slot0(); // Full-Range: always in range. frTotalBlocks++; frBlocksInRange++; // Fixed-Width: track in-range blocks. fwTotalBlocks++; if (fwLiquidity > 0 && currentTick >= fwLo && currentTick < fwHi) { fwBlocksInRange++; } // Fixed-Width rebalance: check every recenterInterval blocks. if (fwLiquidity > 0 && blockNum >= fwLastCheckBlock + recenterInterval) { fwLastCheckBlock = blockNum; if (currentTick < fwLo || currentTick >= fwHi) { _rebalanceFixedWidth(currentTick); } } } // ------------------------------------------------------------------------- // Final summary // ------------------------------------------------------------------------- /** * @notice Log final metrics for all three strategies. Call once after replay ends. */ function logFinalSummary() external view { (uint160 sqrtPriceX96,,,,,,) = pool.slot0(); // ----- HODL ----- uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96); int256 hodlNetPnL = int256(hodlFinalValue) - int256(hodlInitialValueToken0); console2.log("[BASELINE][HODL][SUMMARY] === HODL ==="); console2.log( string.concat( "[BASELINE][HODL][SUMMARY] initialValue=", hodlInitialValueToken0.str(), " finalValue=", hodlFinalValue.str(), " netPnL=", hodlNetPnL.istr() ) ); // ----- Full-Range LP ----- { (uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96); uint256 frFinalPos = _valueInToken0(fr0, fr1, sqrtPriceX96); uint256 frTotalFees = _valueInToken0(frFees0, frFees1, sqrtPriceX96); uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96); int256 frIL = int256(frFinalPos) - int256(frHodlVal); int256 frNetPnL = frIL + int256(frTotalFees); uint256 frTIR = frTotalBlocks > 0 ? (frBlocksInRange * 10_000) / frTotalBlocks : 10_000; console2.log("[BASELINE][FR][SUMMARY] === Full-Range LP ==="); console2.log( string.concat( "[BASELINE][FR][SUMMARY] initialValue=", frInitialValueToken0.str(), " finalPosValue=", frFinalPos.str(), " feesToken0=", frTotalFees.str(), " IL=", frIL.istr(), " netPnL=", frNetPnL.istr(), " timeInRange=", frTIR.str(), " bps" ) ); } // ----- Fixed-Width LP ----- { (uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96); uint256 fwFinalPos = _valueInToken0(fw0, fw1, sqrtPriceX96); uint256 fwFinalFees = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96); // Current period IL (open position vs holding current period entry amounts). uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96); int256 fwPeriodIL = int256(fwFinalPos) - int256(fwPeriodHodlVal); int256 fwTotalIL = fwCumulativeIL + fwPeriodIL; int256 fwNetPnL = fwTotalIL + int256(fwFinalFees); uint256 fwTIR = fwTotalBlocks > 0 ? (fwBlocksInRange * 10_000) / fwTotalBlocks : 0; console2.log("[BASELINE][FW][SUMMARY] === Fixed-Width LP ==="); console2.log( string.concat( "[BASELINE][FW][SUMMARY] initialValue=", fwInitialValueToken0.str(), " finalPosValue=", fwFinalPos.str(), " feesToken0=", fwFinalFees.str(), " cumulativeIL=", fwCumulativeIL.istr(), " currentPeriodIL=", fwPeriodIL.istr(), " totalIL=", fwTotalIL.istr(), " netPnL=", fwNetPnL.istr(), " rebalances=", fwRebalances.str(), " timeInRange=", fwTIR.str(), " bps" ) ); } } /** * @notice Return final computed metrics for each strategy. Used by Reporter. * @dev Computes open-position IL at current price without mutating state. */ function getResults() external view returns (StrategyResult memory hodlResult, StrategyResult memory frResult, StrategyResult memory fwResult) { (uint160 sqrtPriceX96,,,,,,) = pool.slot0(); // ----- HODL ----- uint256 hodlFinalValue = _valueInToken0(hodlEntryToken0, hodlEntryToken1, sqrtPriceX96); hodlResult = StrategyResult({ initialCapitalToken0: hodlInitialValueToken0, finalValueToken0: hodlFinalValue, feesToken0: 0, rebalances: 0, ilToken0: 0, netPnLToken0: int256(hodlFinalValue) - int256(hodlInitialValueToken0), blocksInRange: 0, totalBlocks: 0 }); // ----- Full-Range LP ----- { (uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96); uint256 frPosValue = _valueInToken0(fr0, fr1, sqrtPriceX96); uint256 frFeesT0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96); uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96); int256 frIL = int256(frPosValue) - int256(frHodlVal); int256 frNetPnL = frIL + int256(frFeesT0); frResult = StrategyResult({ initialCapitalToken0: frInitialValueToken0, finalValueToken0: uint256(int256(frInitialValueToken0) + frNetPnL), feesToken0: frFeesT0, rebalances: 0, ilToken0: frIL, netPnLToken0: frNetPnL, blocksInRange: frBlocksInRange, totalBlocks: frTotalBlocks }); } // ----- Fixed-Width LP ----- { (uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96); uint256 fwPosValue = _valueInToken0(fw0, fw1, sqrtPriceX96); uint256 fwFeesT0 = _valueInToken0(fwFees0, fwFees1, sqrtPriceX96); // Current period IL. uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96); int256 fwPeriodIL = int256(fwPosValue) - int256(fwPeriodHodlVal); int256 fwTotalIL = fwCumulativeIL + fwPeriodIL; int256 fwNetPnL = fwTotalIL + int256(fwFeesT0); fwResult = StrategyResult({ initialCapitalToken0: fwInitialValueToken0, finalValueToken0: uint256(int256(fwInitialValueToken0) + fwNetPnL), feesToken0: fwFeesT0, rebalances: fwRebalances, ilToken0: fwTotalIL, netPnLToken0: fwNetPnL, blocksInRange: fwBlocksInRange, totalBlocks: fwTotalBlocks }); } } // ------------------------------------------------------------------------- // Uniswap V3 mint callback // ------------------------------------------------------------------------- /// @inheritdoc IUniswapV3MintCallback function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external override { require(msg.sender == address(pool), "BaselineStrategies: bad mint callback"); if (amount0Owed > 0) { uint256 have = token0.balanceOf(address(this)); if (have < amount0Owed) token0.mint(address(this), amount0Owed - have); token0.transfer(address(pool), amount0Owed); } if (amount1Owed > 0) { uint256 have = token1.balanceOf(address(this)); if (have < amount1Owed) token1.mint(address(this), amount1Owed - have); token1.transfer(address(pool), amount1Owed); } } // ------------------------------------------------------------------------- // Internal: FixedWidth rebalancing // ------------------------------------------------------------------------- function _rebalanceFixedWidth(int24 currentTick) internal { (uint160 sqrtPriceX96,,,,,, ) = pool.slot0(); // Burn current position and collect principal + fees. (uint256 p0, uint256 p1) = pool.burn(fwLo, fwHi, fwLiquidity); (uint128 c0, uint128 c1) = pool.collect(address(this), fwLo, fwHi, type(uint128).max, type(uint128).max); // fees = collected - principal uint256 f0 = uint256(c0) > p0 ? uint256(c0) - p0 : 0; uint256 f1 = uint256(c1) > p1 ? uint256(c1) - p1 : 0; fwFees0 += f0; fwFees1 += f1; fwTotalFeesToken0 += _valueInToken0(f0, f1, sqrtPriceX96); // IL for this closed period: LP exit value vs holding entry amounts at exit price. uint256 exitVal = _valueInToken0(p0, p1, sqrtPriceX96); uint256 hodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96); fwCumulativeIL += int256(exitVal) - int256(hodlVal); fwRebalances++; fwLiquidity = 0; // Deploy new position centered around current tick. (int24 newLo, int24 newHi) = _computeFixedRange(currentTick); uint256 avail0 = uint256(c0); uint256 avail1 = uint256(c1); uint128 newLiq = LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, TickMath.getSqrtRatioAtTick(newLo), TickMath.getSqrtRatioAtTick(newHi), avail0, avail1 ); if (newLiq > 0) { (uint256 used0, uint256 used1) = pool.mint(address(this), newLo, newHi, newLiq, ""); fwLiquidity = newLiq; fwLo = newLo; fwHi = newHi; fwPeriodEntryToken0 = used0; fwPeriodEntryToken1 = used1; } console2.log( string.concat( "[BASELINE][FW][REBALANCE] #", fwRebalances.str(), " tick=", int256(currentTick).istr(), " oldRange=[", int256(fwLo).istr(), ",", int256(fwHi).istr(), "] newRange=[", int256(newLo).istr(), ",", int256(newHi).istr(), "] fees0=", f0.str(), " fees1=", f1.str() ) ); } // ------------------------------------------------------------------------- // Internal: Uniswap V3 math helpers // ------------------------------------------------------------------------- function _positionAmounts( uint128 liquidity, int24 tickLower, int24 tickUpper, uint160 sqrtPriceX96 ) internal pure returns (uint256 amount0, uint256 amount1) { if (liquidity == 0) return (0, 0); uint160 sqrtLow = TickMath.getSqrtRatioAtTick(tickLower); uint160 sqrtHigh = TickMath.getSqrtRatioAtTick(tickUpper); (amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtLow, sqrtHigh, liquidity); } /** * @notice Convert (amount0, amount1) to token0-equivalent units. * value = amount0 + amount1 × Q96² / sqrtPriceX96² */ function _valueInToken0(uint256 amount0, uint256 amount1, uint160 sqrtPriceX96) internal pure returns (uint256) { if (sqrtPriceX96 == 0 || amount1 == 0) return amount0; uint256 amt1InT0 = Math.mulDiv(Math.mulDiv(amount1, Q96, uint256(sqrtPriceX96)), Q96, uint256(sqrtPriceX96)); return amount0 + amt1InT0; } /** * @notice Convert a token0 amount to its token1 equivalent at the current price. * amount1 = amount0 × sqrtPriceX96² / Q96² */ function _valueInToken1(uint256 amount0, uint160 sqrtPriceX96) internal pure returns (uint256) { if (sqrtPriceX96 == 0 || amount0 == 0) return 0; return Math.mulDiv(Math.mulDiv(amount0, uint256(sqrtPriceX96), Q96), uint256(sqrtPriceX96), Q96); } // ------------------------------------------------------------------------- // Internal: tick range helpers // ------------------------------------------------------------------------- /// @notice Compute the full-range lower tick (smallest valid tick aligned to tickSpacing). function _fullRangeLo() internal view returns (int24) { // Solidity truncation toward zero = ceiling for negatives → smallest valid multiple >= MIN_TICK. return (TickMath.MIN_TICK / tickSpacing) * tickSpacing; } /// @notice Compute the full-range upper tick (largest valid tick aligned to tickSpacing). function _fullRangeHi() internal view returns (int24) { return (TickMath.MAX_TICK / tickSpacing) * tickSpacing; } /** * @notice Compute ±FIXED_WIDTH_TICKS range centered on `currentTick`, aligned to tickSpacing. * lo is aligned DOWN (floor); hi is aligned UP (ceiling). * Result is clamped to the full-range bounds. */ function _computeFixedRange(int24 currentTick) internal view returns (int24 lo, int24 hi) { int24 rawLo = currentTick - FIXED_WIDTH_TICKS; int24 rawHi = currentTick + FIXED_WIDTH_TICKS; // Floor for lo (align down toward more-negative). lo = (rawLo / tickSpacing) * tickSpacing; if (rawLo < 0 && rawLo % tickSpacing != 0) lo -= tickSpacing; // Ceiling for hi (align up toward more-positive). hi = (rawHi / tickSpacing) * tickSpacing; if (rawHi > 0 && rawHi % tickSpacing != 0) hi += tickSpacing; // Clamp to valid pool range. int24 minValid = _fullRangeLo(); int24 maxValid = _fullRangeHi(); if (lo < minValid) lo = minValid; if (hi > maxValid) hi = maxValid; if (lo >= hi) { lo = minValid; hi = maxValid; } } }