diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index 0f9b954..dcf00d6 100644 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -2,10 +2,11 @@ pragma solidity ^0.8.19; import { BacktestKraiken } from "./BacktestKraiken.sol"; - +import { BaselineStrategies } from "./BaselineStrategies.sol"; import { EventReplayer } from "./EventReplayer.sol"; import { KrAIkenDeployer, KrAIkenSystem } from "./KrAIkenDeployer.sol"; import { MockToken } from "./MockToken.sol"; +import { Reporter } from "./Reporter.sol"; import { ShadowPool, ShadowPoolDeployer } from "./ShadowPoolDeployer.sol"; import { StrategyExecutor } from "./StrategyExecutor.sol"; import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; @@ -151,8 +152,17 @@ contract BacktestRunner is Script { new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth); sys.lm.setRecenterAccess(address(executor)); + // Deploy baseline strategies and initialize with the same capital as KrAIken. + BaselineStrategies baselines = + new BaselineStrategies(sp.pool, MockToken(sp.token0), MockToken(sp.token1), token0isWeth, recenterInterval); + baselines.initialize(initialCapital); + vm.stopBroadcast(); + // Reporter uses Foundry cheatcodes (vm.writeFile) — must live outside the broadcast + // block so it is never sent as a real transaction on a live fork. + Reporter reporter = new Reporter(); + // ------------------------------------------------------------------ // EventReplayer is instantiated outside the broadcast block because // it uses Foundry cheat codes (vm.readLine, vm.roll, vm.warp) that @@ -187,6 +197,8 @@ contract BacktestRunner is Script { console2.log("LiquidityMgr: ", address(sys.lm)); console2.log("StrategyExec: ", address(executor)); console2.log("PositionTracker:", address(executor.tracker())); + console2.log("BaselineStrats: ", address(baselines)); + console2.log("Reporter: ", address(reporter)); console2.log("Recenter intv: ", recenterInterval, " blocks"); console2.log("Initial capital:", initialCapital, " (mock WETH wei)"); console2.log("token0isWeth: ", token0isWeth); @@ -218,10 +230,37 @@ contract BacktestRunner is Script { console2.log("\n=== Starting Event Replay ==="); console2.log("Events file: ", eventsFile); console2.log("Total events: ", totalEvents); - replayer.replay(eventsFile, totalEvents, executor); + replayer.replay(eventsFile, totalEvents, executor, baselines); // Print final KrAIken strategy summary. executor.logSummary(); + + // Materialize open-position fees for both LP baselines before summary/reporting. + baselines.collectFinalFees(); + + // Print baseline summaries (now have complete fee data). + baselines.logFinalSummary(); + + // Generate comparison report (Markdown + JSON). + // firstUpdateBlock and lastNotifiedBlock are the actual block numbers from the + // events file — use them directly for an accurate period estimate. + uint256 startBlock = baselines.firstUpdateBlock(); + uint256 endBlock = executor.tracker().lastNotifiedBlock(); + uint256 periodDays = endBlock > startBlock ? ((endBlock - startBlock) * 2) / 86_400 : 0; + + reporter.generate( + executor, + baselines, + Reporter.Config({ + poolAddress: address(sp.pool), + startBlock: startBlock, + endBlock: endBlock, + initialCapital: initialCapital, + recenterInterval: recenterInterval, + poolLabel: "AERO/WETH 1%", + periodDays: periodDays + }) + ); } } catch { } } diff --git a/onchain/script/backtesting/BaselineStrategies.sol b/onchain/script/backtesting/BaselineStrategies.sol new file mode 100644 index 0000000..922d750 --- /dev/null +++ b/onchain/script/backtesting/BaselineStrategies.sol @@ -0,0 +1,680 @@ +// 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. + * + * Call order: + * 1. initialize(initialCapital) — during vm.startBroadcast() + * 2. maybeUpdate(blockNum) — called by EventReplayer on every block during replay + * 3. collectFinalFees() — after replay, before logFinalSummary / getResults + * 4. logFinalSummary() / getResults() — view, rely on collectFinalFees() having run + * + * Capital: + * All strategies receive the same initial capital split 50/50 by value. + * HODL holds the same token amounts that Full-Range LP actually deployed, so the + * HODL vs LP comparison is perfectly apples-to-apples. + * + * Fee accounting: + * Fixed-Width fees are accumulated at each rebalance's contemporaneous sqrtPriceX96 into + * fwTotalFeesToken0. Full-Range fees are collected once (in collectFinalFees) at the final + * price into frTotalFeesToken0. Both accumulators are used directly by getResults(). + * + * 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; + /// @notice Token amounts deployed into the Full-Range position at entry. + uint256 public frEntryToken0; + uint256 public frEntryToken1; + uint256 public frInitialValueToken0; + /// @notice Raw fee totals (populated by collectFinalFees). + uint256 public frFees0; + uint256 public frFees1; + /// @notice Fees in token0-equivalent units at collection price (set by collectFinalFees). + uint256 public frTotalFeesToken0; + 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; + uint256 public fwInitialValueToken0; + /// @notice Raw fee totals accumulated across all periods. + uint256 public fwFees0; + uint256 public fwFees1; + /// @notice Fees accumulated at contemporaneous prices across all closed periods, + /// plus the current period fees added by collectFinalFees(). + 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; + /// @notice First block number observed during replay (set on first maybeUpdate call). + uint256 public firstUpdateBlock; + + // ------------------------------------------------------------------------- + // 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: half in token0, the equivalent in token1. + * Full-Range LP is deployed first; HODL holds the same token amounts that + * Full-Range actually consumed so both strategies start with identical capital. + */ + 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); + + // ----- Full-Range LP (deployed first so HODL can mirror its amounts) ----- + { + 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); + } + } + + // ----- HODL: hold the same amounts Full-Range actually deployed ----- + // Falls back to half0/half1 if FullRange mint produced zero (edge case). + if (frEntryToken0 > 0 || frEntryToken1 > 0) { + hodlEntryToken0 = frEntryToken0; + hodlEntryToken1 = frEntryToken1; + hodlInitialValueToken0 = frInitialValueToken0; + } else { + hodlEntryToken0 = half0; + hodlEntryToken1 = half1; + hodlInitialValueToken0 = _valueInToken0(half0, half1, 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; + 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; + + // Record the first block seen during replay for accurate period reporting. + if (firstUpdateBlock == 0) firstUpdateBlock = 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); + } + } + } + + // ------------------------------------------------------------------------- + // Post-replay fee collection (MUST be called before logFinalSummary / getResults) + // ------------------------------------------------------------------------- + + /** + * @notice Materialize accrued fees for the still-open Full-Range and Fixed-Width positions. + * Uses pool.burn(lo, hi, 0) to trigger fee accrual without removing liquidity, + * then pool.collect() to sweep fees to this contract. + * + * Call once after replay ends and before any summary/reporting functions. + * Idempotent if positions have zero pending fees. + */ + function collectFinalFees() external { + (uint160 sqrtPriceX96,,,,,,) = pool.slot0(); + + // ----- Full-Range: single collection at end-of-replay price ----- + if (fullRangeLiquidity > 0) { + pool.burn(frLo, frHi, 0); // trigger fee accrual; no liquidity removed + (uint128 f0, uint128 f1) = pool.collect(address(this), frLo, frHi, type(uint128).max, type(uint128).max); + frFees0 += uint256(f0); + frFees1 += uint256(f1); + // Value all Full-Range fees at current price (only one collection, so no + // historical repricing concern for this strategy). + frTotalFeesToken0 = _valueInToken0(frFees0, frFees1, sqrtPriceX96); + } + + // ----- Fixed-Width: current open period fees (historical fees already in fwTotalFeesToken0) ----- + if (fwLiquidity > 0) { + pool.burn(fwLo, fwHi, 0); // trigger fee accrual; no liquidity removed + (uint128 f0, uint128 f1) = pool.collect(address(this), fwLo, fwHi, type(uint128).max, type(uint128).max); + fwFees0 += uint256(f0); + fwFees1 += uint256(f1); + // Add current-period fees at current price to the historical-price accumulator. + fwTotalFeesToken0 += _valueInToken0(uint256(f0), uint256(f1), sqrtPriceX96); + } + } + + // ------------------------------------------------------------------------- + // Final summary (view — requires collectFinalFees() to have been called first) + // ------------------------------------------------------------------------- + + /** + * @notice Log final metrics for all three strategies. Call once after collectFinalFees(). + */ + 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); + // frTotalFeesToken0 populated by collectFinalFees(). + uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96); + int256 frIL = int256(frFinalPos) - int256(frHodlVal); + int256 frNetPnL = frIL + int256(frTotalFeesToken0); + uint256 frFinalValue = frFinalPos + frTotalFeesToken0; + 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(), + " finalValue=", + frFinalValue.str(), + " feesToken0=", + frTotalFeesToken0.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); + // fwTotalFeesToken0: historical periods at contemporaneous prices + current period at current + // price (added by collectFinalFees()). + uint256 fwFinalValue = fwFinalPos + fwTotalFeesToken0; + // 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(fwTotalFeesToken0); + 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(), + " finalValue=", + fwFinalValue.str(), + " feesToken0=", + fwTotalFeesToken0.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 Requires collectFinalFees() to have been called first so fee accumulators are complete. + * finalValueToken0 = positionValue + feesToken0 (both non-negative, no underflow risk). + */ + 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); + // finalValue = position value + fees (always non-negative). + uint256 frFinalValue = frPosValue + frTotalFeesToken0; + uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96); + int256 frIL = int256(frPosValue) - int256(frHodlVal); + int256 frNetPnL = int256(frFinalValue) - int256(frInitialValueToken0); + frResult = StrategyResult({ + initialCapitalToken0: frInitialValueToken0, + finalValueToken0: frFinalValue, + feesToken0: frTotalFeesToken0, + 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); + // finalValue = position value + all fees (always non-negative). + uint256 fwFinalValue = fwPosValue + fwTotalFeesToken0; + // Current period IL. + uint256 fwPeriodHodlVal = _valueInToken0(fwPeriodEntryToken0, fwPeriodEntryToken1, sqrtPriceX96); + int256 fwPeriodIL = int256(fwPosValue) - int256(fwPeriodHodlVal); + int256 fwTotalIL = fwCumulativeIL + fwPeriodIL; + int256 fwNetPnL = int256(fwFinalValue) - int256(fwInitialValueToken0); + fwResult = StrategyResult({ + initialCapitalToken0: fwInitialValueToken0, + finalValueToken0: fwFinalValue, + feesToken0: fwTotalFeesToken0, + 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(); + + // Capture old range before any mutation — used in the log line below. + int24 oldLo = fwLo; + int24 oldHi = fwHi; + + // 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; + // Accumulate at contemporaneous price (more accurate than repricing at end). + 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; + } + + // Log uses oldLo/oldHi captured before fwLo/fwHi were overwritten. + console2.log( + string.concat( + "[BASELINE][FW][REBALANCE] #", + fwRebalances.str(), + " tick=", + int256(currentTick).istr(), + " oldRange=[", + int256(oldLo).istr(), + ",", + int256(oldHi).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; + } + } +} diff --git a/onchain/script/backtesting/EventReplayer.sol b/onchain/script/backtesting/EventReplayer.sol index 16f2937..016770b 100644 --- a/onchain/script/backtesting/EventReplayer.sol +++ b/onchain/script/backtesting/EventReplayer.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.19; +import { BaselineStrategies } from "./BaselineStrategies.sol"; import { MockToken } from "./MockToken.sol"; import { StrategyExecutor } from "./StrategyExecutor.sol"; import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol"; @@ -94,7 +95,7 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { * Pass 0 to omit the denominator from progress logs. */ function replay(string memory eventsFile, uint256 totalEvents) external { - _replayWithStrategy(eventsFile, totalEvents, StrategyExecutor(address(0))); + _replayWithStrategy(eventsFile, totalEvents, StrategyExecutor(address(0)), BaselineStrategies(address(0))); } /** @@ -105,10 +106,35 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { * Pass address(0) to disable strategy integration. */ function replay(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) external { - _replayWithStrategy(eventsFile, totalEvents, strategyExecutor); + _replayWithStrategy(eventsFile, totalEvents, strategyExecutor, BaselineStrategies(address(0))); } - function _replayWithStrategy(string memory eventsFile, uint256 totalEvents, StrategyExecutor strategyExecutor) internal { + /** + * @notice Replay events, trigger KrAIken recenter, and update baseline strategies — all in one pass. + * @param eventsFile Path to the .jsonl events cache. + * @param totalEvents Total event count (for progress logs). + * @param strategyExecutor KrAIken StrategyExecutor (pass address(0) to disable). + * @param baselines Baseline strategies to update each block (pass address(0) to disable). + */ + function replay( + string memory eventsFile, + uint256 totalEvents, + StrategyExecutor strategyExecutor, + BaselineStrategies baselines + ) + external + { + _replayWithStrategy(eventsFile, totalEvents, strategyExecutor, baselines); + } + + function _replayWithStrategy( + string memory eventsFile, + uint256 totalEvents, + StrategyExecutor strategyExecutor, + BaselineStrategies baselines + ) + internal + { uint256 idx = 0; // Track the last Swap event's expected state for drift measurement. @@ -136,6 +162,11 @@ contract EventReplayer is IUniswapV3MintCallback, IUniswapV3SwapCallback { strategyExecutor.maybeRecenter(blockNum); } + // Update baseline strategies for the same block. + if (address(baselines) != address(0)) { + baselines.maybeUpdate(blockNum); + } + if (_streq(eventName, "Swap")) { (int24 expTick, uint160 expSqrtPrice) = _replaySwap(line); // Update reference state only when the swap was not skipped. diff --git a/onchain/script/backtesting/PositionTracker.sol b/onchain/script/backtesting/PositionTracker.sol index 149b672..2e08d86 100644 --- a/onchain/script/backtesting/PositionTracker.sol +++ b/onchain/script/backtesting/PositionTracker.sol @@ -238,6 +238,42 @@ contract PositionTracker { _openPosition(2, newPos.discLiq, newPos.discLo, newPos.discHi, sqrtPriceX96, blockNum, timestamp); } + // ------------------------------------------------------------------------- + // View: final computed metrics (consumed by Reporter) + // ------------------------------------------------------------------------- + + struct FinalData { + int256 finalIL; + int256 finalNetPnL; + uint256 finalFeesToken0; + uint256 rebalances; + uint256 blocksInRange; + uint256 totalBlocks; + } + + /** + * @notice Compute final aggregate metrics including open-position IL. + * Returns the same values that logFinalSummary() logs. + */ + function getFinalData() external view returns (FinalData memory d) { + (uint160 sqrtPriceX96,,,,,,) = pool.slot0(); + d.finalIL = totalILToken0; + d.finalNetPnL = totalNetPnLToken0; + d.finalFeesToken0 = totalFeesToken0; + d.rebalances = rebalanceCount; + d.blocksInRange = blocksAnchorInRange; + d.totalBlocks = blocksChecked; + + for (uint8 i = 0; i < 3; i++) { + OpenPosition storage pos = openPositions[i]; + if (!pos.active) continue; + (uint256 exitAmt0, uint256 exitAmt1) = _positionAmounts(pos.liquidity, pos.tickLower, pos.tickUpper, sqrtPriceX96); + int256 il = _computeIL(pos.entryAmount0, pos.entryAmount1, exitAmt0, exitAmt1, sqrtPriceX96); + d.finalIL += il; + d.finalNetPnL += il; // no fees for unclosed positions + } + } + /** * @notice Log the final aggregate summary. Call once at the end of replay. * @param blockNum Final block number (for context in the summary line). diff --git a/onchain/script/backtesting/Reporter.sol b/onchain/script/backtesting/Reporter.sol new file mode 100644 index 0000000..6e62120 --- /dev/null +++ b/onchain/script/backtesting/Reporter.sol @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { BaselineStrategies } from "./BaselineStrategies.sol"; +import { FormatLib } from "./FormatLib.sol"; +import { PositionTracker } from "./PositionTracker.sol"; +import { StrategyExecutor } from "./StrategyExecutor.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { console2 } from "forge-std/console2.sol"; + +/** + * @title Reporter + * @notice Generates a Markdown + JSON backtest report comparing KrAIken's 3-position strategy + * against three baseline strategies (HODL, Full-Range LP, Fixed-Width LP). + * + * Output files (relative to the forge project root): + * script/backtesting/reports/report-{endBlock}.md + * script/backtesting/reports/report-{endBlock}.json + * + * Requires Foundry cheatcodes (vm.writeFile) and read-write fs_permissions. + * + * WARNING — backtesting use only. Not a deployable production contract. + */ +contract Reporter { + using FormatLib for uint256; + using FormatLib for int256; + + // ------------------------------------------------------------------------- + // Foundry cheatcode handle (same pattern as EventReplayer) + // ------------------------------------------------------------------------- + + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + // ------------------------------------------------------------------------- + // Config struct (passed to generate()) + // ------------------------------------------------------------------------- + + struct Config { + address poolAddress; + uint256 startBlock; + uint256 endBlock; + uint256 initialCapital; // token0 units (wei) + uint256 recenterInterval; + string poolLabel; // e.g. "AERO/WETH 1%" + uint256 periodDays; + } + + // ------------------------------------------------------------------------- + // Row data (internal working struct) + // ------------------------------------------------------------------------- + + struct Row { + string name; + uint256 initialCapital; + uint256 finalValue; + uint256 feesToken0; + uint256 rebalances; + int256 ilToken0; + int256 netPnLToken0; + uint256 blocksInRange; + uint256 totalBlocks; + bool isHodl; // suppresses time-in-range column, shows $0 fees + } + + // ------------------------------------------------------------------------- + // Main entry point + // ------------------------------------------------------------------------- + + /** + * @notice Collect metrics from all strategies, write Markdown and JSON reports. + * @param kraiken StrategyExecutor for the KrAIken 3-position strategy. + * @param baselines Baseline strategies contract. + * @param cfg Report configuration. + */ + function generate(StrategyExecutor kraiken, BaselineStrategies baselines, Config calldata cfg) external { + PositionTracker tracker = kraiken.tracker(); + PositionTracker.FinalData memory fd = tracker.getFinalData(); + + // Build KrAIken row from PositionTracker data. + Row memory kRow = Row({ + name: "KrAIken 3-Pos", + initialCapital: cfg.initialCapital, + finalValue: uint256(int256(cfg.initialCapital) + fd.finalNetPnL), + feesToken0: fd.finalFeesToken0, + rebalances: fd.rebalances, + ilToken0: fd.finalIL, + netPnLToken0: fd.finalNetPnL, + blocksInRange: fd.blocksInRange, + totalBlocks: fd.totalBlocks, + isHodl: false + }); + + // Collect baseline results. + (BaselineStrategies.StrategyResult memory hodlR, BaselineStrategies.StrategyResult memory frR, BaselineStrategies.StrategyResult memory fwR) = + baselines.getResults(); + + Row memory frRow = Row({ + name: "Full-Range LP", + initialCapital: frR.initialCapitalToken0, + finalValue: frR.finalValueToken0, + feesToken0: frR.feesToken0, + rebalances: frR.rebalances, + ilToken0: frR.ilToken0, + netPnLToken0: frR.netPnLToken0, + blocksInRange: frR.blocksInRange, + totalBlocks: frR.totalBlocks, + isHodl: false + }); + + Row memory fwRow = Row({ + name: "Fixed-Width LP", + initialCapital: fwR.initialCapitalToken0, + finalValue: fwR.finalValueToken0, + feesToken0: fwR.feesToken0, + rebalances: fwR.rebalances, + ilToken0: fwR.ilToken0, + netPnLToken0: fwR.netPnLToken0, + blocksInRange: fwR.blocksInRange, + totalBlocks: fwR.totalBlocks, + isHodl: false + }); + + Row memory hodlRow = Row({ + name: "HODL", + initialCapital: hodlR.initialCapitalToken0, + finalValue: hodlR.finalValueToken0, + feesToken0: 0, + rebalances: 0, + ilToken0: 0, + netPnLToken0: hodlR.netPnLToken0, + blocksInRange: 0, + totalBlocks: 0, + isHodl: true + }); + + // Write files. + string memory suffix = cfg.endBlock.str(); + string memory mdPath = string.concat("script/backtesting/reports/report-", suffix, ".md"); + string memory jsonPath = string.concat("script/backtesting/reports/report-", suffix, ".json"); + + vm.writeFile(mdPath, _buildMarkdown(kRow, frRow, fwRow, hodlRow, cfg)); + console2.log(string.concat("[REPORTER] Markdown: ", mdPath)); + + vm.writeFile(jsonPath, _buildJson(kRow, frRow, fwRow, hodlRow, cfg)); + console2.log(string.concat("[REPORTER] JSON: ", jsonPath)); + } + + // ------------------------------------------------------------------------- + // Markdown generation + // ------------------------------------------------------------------------- + + function _buildMarkdown(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg) + internal + pure + returns (string memory) + { + return string.concat( + "# Backtest Report: ", + cfg.poolLabel, + ", ", + cfg.periodDays.str(), + " days\n\n", + "| Strategy | Final Value (token0) | Fees Earned (token0) | Rebalances |" + " IL (%) | Net P&L (%) | Time in Range |\n", + "|----------------|----------------------|----------------------|------------|" + "---------|-------------|---------------|\n", + _mdRow(k), + _mdRow(fr), + _mdRow(fw), + _mdRow(hodl), + "\n## Configuration\n\n", + "- Pool: `", + _addrStr(cfg.poolAddress), + "`\n", + "- Period: block ", + cfg.startBlock.str(), + " to block ", + cfg.endBlock.str(), + " (", + cfg.periodDays.str(), + " days)\n", + "- Initial capital: ", + _etherStr(cfg.initialCapital), + " ETH equivalent\n", + "- Recenter interval: ", + cfg.recenterInterval.str(), + " blocks\n" + ); + } + + function _mdRow(Row memory r) internal pure returns (string memory) { + string memory tir; + if (r.isHodl) { + tir = "N/A"; + } else if (r.totalBlocks == 0) { + tir = "0.00%"; + } else { + tir = _bpsStr((r.blocksInRange * 10_000) / r.totalBlocks); + } + + string memory fees = r.isHodl ? "$0" : _etherStr(r.feesToken0); + string memory il = r.isHodl ? "0" : _signedPctStr(r.ilToken0, r.initialCapital); + + return string.concat( + "| ", + _pad(r.name, 14), + " | ", + _etherStr(r.finalValue), + " | ", + fees, + " | ", + r.rebalances.str(), + " | ", + il, + " | ", + _signedPctStr(r.netPnLToken0, r.initialCapital), + " | ", + tir, + " |\n" + ); + } + + // ------------------------------------------------------------------------- + // JSON generation + // ------------------------------------------------------------------------- + + function _buildJson(Row memory k, Row memory fr, Row memory fw, Row memory hodl, Config calldata cfg) + internal + pure + returns (string memory) + { + return string.concat( + "{\n", + ' "strategies": {\n', + ' "kraiken": ', + _jsonRow(k), + ",\n", + ' "fullRange": ', + _jsonRow(fr), + ",\n", + ' "fixedWidth": ', + _jsonRow(fw), + ",\n", + ' "hodl": ', + _jsonRow(hodl), + "\n", + " },\n", + ' "config": {\n', + ' "pool": "', + _addrStr(cfg.poolAddress), + '",\n', + ' "poolLabel": "', + cfg.poolLabel, + '",\n', + ' "startBlock": ', + cfg.startBlock.str(), + ",\n", + ' "endBlock": ', + cfg.endBlock.str(), + ",\n", + ' "periodDays": ', + cfg.periodDays.str(), + ",\n", + ' "initialCapital": "', + cfg.initialCapital.str(), + '",\n', + ' "recenterInterval": ', + cfg.recenterInterval.str(), + "\n", + " }\n", + "}\n" + ); + } + + function _jsonRow(Row memory r) internal pure returns (string memory) { + string memory tir = r.isHodl ? "null" : r.totalBlocks.str(); + string memory inRange = r.isHodl ? "null" : r.blocksInRange.str(); + return string.concat( + "{\n", + ' "initialCapitalToken0": "', + r.initialCapital.str(), + '",\n', + ' "finalValueToken0": "', + r.finalValue.str(), + '",\n', + ' "feesToken0": "', + r.feesToken0.str(), + '",\n', + ' "rebalances": ', + r.rebalances.str(), + ",\n", + ' "ilToken0": "', + r.ilToken0.istr(), + '",\n', + ' "netPnLToken0": "', + r.netPnLToken0.istr(), + '",\n', + ' "blocksInRange": ', + inRange, + ",\n", + ' "totalBlocks": ', + tir, + "\n", + " }" + ); + } + + // ------------------------------------------------------------------------- + // Formatting helpers + // ------------------------------------------------------------------------- + + /** + * @notice Format wei as ".<6 decimals>" (without "ETH" suffix — unit depends on context). + */ + function _etherStr(uint256 wei_) internal pure returns (string memory) { + uint256 whole = wei_ / 1e18; + uint256 frac = (wei_ % 1e18) / 1e12; // 6 decimal places + string memory fracStr = frac.str(); + uint256 len = bytes(fracStr).length; + string memory pad = ""; + for (uint256 i = len; i < 6; i++) { + pad = string.concat("0", pad); + } + return string.concat(whole.str(), ".", pad, fracStr); + } + + /** + * @notice Format a signed ratio (value/base) as a percentage with 2 decimals: "±X.XX%". + */ + function _signedPctStr(int256 value, uint256 base) internal pure returns (string memory) { + if (base == 0) return "0.00%"; + bool neg = value < 0; + uint256 abs = neg ? uint256(-value) : uint256(value); + uint256 bps = (abs * 10_000) / base; + return string.concat(neg ? "-" : "", _bpsStr(bps)); + } + + /** + * @notice Format bps (0..10000+) as "X.XX%". + */ + function _bpsStr(uint256 bps) internal pure returns (string memory) { + uint256 whole = bps / 100; + uint256 dec = bps % 100; + return string.concat(whole.str(), ".", dec < 10 ? string.concat("0", dec.str()) : dec.str(), "%"); + } + + /** + * @notice Right-pad string to `width` with spaces. + */ + function _pad(string memory s, uint256 width) internal pure returns (string memory) { + uint256 len = bytes(s).length; + if (len >= width) return s; + string memory out = s; + for (uint256 i = len; i < width; i++) { + out = string.concat(out, " "); + } + return out; + } + + /** + * @notice Encode an address as a lowercase hex string "0x...". + */ + function _addrStr(address addr) internal pure returns (string memory) { + bytes memory b = abi.encodePacked(addr); + bytes memory h = new bytes(42); + h[0] = "0"; + h[1] = "x"; + for (uint256 i = 0; i < 20; i++) { + h[2 + i * 2] = _hexChar(uint8(b[i]) >> 4); + h[3 + i * 2] = _hexChar(uint8(b[i]) & 0x0f); + } + return string(h); + } + + function _hexChar(uint8 v) internal pure returns (bytes1) { + return v < 10 ? bytes1(48 + v) : bytes1(87 + v); + } +} diff --git a/onchain/script/backtesting/reports/.gitkeep b/onchain/script/backtesting/reports/.gitkeep new file mode 100644 index 0000000..e69de29