diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index b97f12b..dcf00d6 100644 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -157,11 +157,12 @@ contract BacktestRunner is Script { new BaselineStrategies(sp.pool, MockToken(sp.token0), MockToken(sp.token1), token0isWeth, recenterInterval); baselines.initialize(initialCapital); - // Deploy Reporter (no broadcast needed — it only writes files). - Reporter reporter = new Reporter(); - 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 @@ -231,19 +232,21 @@ contract BacktestRunner is Script { console2.log("Total events: ", totalEvents); replayer.replay(eventsFile, totalEvents, executor, baselines); - // Print final strategy summaries. + // 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). - // Use fwTotalBlocks as the total replay duration (every unique block processed). - // Approximate period length at ~2 s/block. + // 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 totalBlocksElapsed = baselines.fwTotalBlocks(); - uint256 startBlock = totalBlocksElapsed > 0 && endBlock > totalBlocksElapsed - ? endBlock - totalBlocksElapsed - : endBlock; - uint256 periodDays = (totalBlocksElapsed * 2) / 86_400; + uint256 periodDays = endBlock > startBlock ? ((endBlock - startBlock) * 2) / 86_400 : 0; reporter.generate( executor, diff --git a/onchain/script/backtesting/BaselineStrategies.sol b/onchain/script/backtesting/BaselineStrategies.sol index 7d3d740..922d750 100644 --- a/onchain/script/backtesting/BaselineStrategies.sol +++ b/onchain/script/backtesting/BaselineStrategies.sol @@ -20,13 +20,21 @@ import { console2 } from "forge-std/console2.sol"; * 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. + * 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 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. + * 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. */ @@ -86,11 +94,15 @@ contract BaselineStrategies is IUniswapV3MintCallback { 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; @@ -104,12 +116,12 @@ contract BaselineStrategies is IUniswapV3MintCallback { /// @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; + /// @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). @@ -124,6 +136,8 @@ contract BaselineStrategies is IUniswapV3MintCallback { bool public initialized; uint256 public lastUpdateBlock; + /// @notice First block number observed during replay (set on first maybeUpdate call). + uint256 public firstUpdateBlock; // ------------------------------------------------------------------------- // Constructor @@ -144,8 +158,9 @@ contract BaselineStrategies is IUniswapV3MintCallback { /** * @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. + * 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"); @@ -156,12 +171,7 @@ contract BaselineStrategies is IUniswapV3MintCallback { uint256 half0 = initialCapital / 2; uint256 half1 = _valueInToken1(half0, sqrtPriceX96); - // ----- HODL ----- - hodlEntryToken0 = half0; - hodlEntryToken1 = half1; - hodlInitialValueToken0 = _valueInToken0(half0, half1, sqrtPriceX96); - - // ----- Full-Range LP ----- + // ----- Full-Range LP (deployed first so HODL can mirror its amounts) ----- { int24 lo = _fullRangeLo(); int24 hi = _fullRangeHi(); @@ -181,6 +191,18 @@ contract BaselineStrategies is IUniswapV3MintCallback { } } + // ----- 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); @@ -196,8 +218,6 @@ contract BaselineStrategies is IUniswapV3MintCallback { fwHi = hi; fwPeriodEntryToken0 = used0; fwPeriodEntryToken1 = used1; - fwInitialToken0 = used0; - fwInitialToken1 = used1; fwInitialValueToken0 = _valueInToken0(used0, used1, sqrtPriceX96); } fwLastCheckBlock = block.number; @@ -259,6 +279,9 @@ contract BaselineStrategies is IUniswapV3MintCallback { 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. @@ -281,11 +304,48 @@ contract BaselineStrategies is IUniswapV3MintCallback { } // ------------------------------------------------------------------------- - // Final summary + // Post-replay fee collection (MUST be called before logFinalSummary / getResults) // ------------------------------------------------------------------------- /** - * @notice Log final metrics for all three strategies. Call once after replay ends. + * @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(); @@ -309,20 +369,21 @@ contract BaselineStrategies is IUniswapV3MintCallback { { (uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96); uint256 frFinalPos = _valueInToken0(fr0, fr1, sqrtPriceX96); - uint256 frTotalFees = _valueInToken0(frFees0, frFees1, sqrtPriceX96); + // frTotalFeesToken0 populated by collectFinalFees(). uint256 frHodlVal = _valueInToken0(frEntryToken0, frEntryToken1, sqrtPriceX96); int256 frIL = int256(frFinalPos) - int256(frHodlVal); - int256 frNetPnL = frIL + int256(frTotalFees); + 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(), - " finalPosValue=", - frFinalPos.str(), + " finalValue=", + frFinalValue.str(), " feesToken0=", - frTotalFees.str(), + frTotalFeesToken0.str(), " IL=", frIL.istr(), " netPnL=", @@ -338,22 +399,24 @@ contract BaselineStrategies is IUniswapV3MintCallback { { (uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96); uint256 fwFinalPos = _valueInToken0(fw0, fw1, sqrtPriceX96); - uint256 fwFinalFees = _valueInToken0(fwFees0, fwFees1, 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(fwFinalFees); + 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(), - " finalPosValue=", - fwFinalPos.str(), + " finalValue=", + fwFinalValue.str(), " feesToken0=", - fwFinalFees.str(), + fwTotalFeesToken0.str(), " cumulativeIL=", fwCumulativeIL.istr(), " currentPeriodIL=", @@ -374,7 +437,8 @@ contract BaselineStrategies is IUniswapV3MintCallback { /** * @notice Return final computed metrics for each strategy. Used by Reporter. - * @dev Computes open-position IL at current price without mutating state. + * @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 @@ -400,14 +464,15 @@ contract BaselineStrategies is IUniswapV3MintCallback { { (uint256 fr0, uint256 fr1) = _positionAmounts(fullRangeLiquidity, frLo, frHi, sqrtPriceX96); uint256 frPosValue = _valueInToken0(fr0, fr1, sqrtPriceX96); - uint256 frFeesT0 = _valueInToken0(frFees0, frFees1, 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 = frIL + int256(frFeesT0); + int256 frNetPnL = int256(frFinalValue) - int256(frInitialValueToken0); frResult = StrategyResult({ initialCapitalToken0: frInitialValueToken0, - finalValueToken0: uint256(int256(frInitialValueToken0) + frNetPnL), - feesToken0: frFeesT0, + finalValueToken0: frFinalValue, + feesToken0: frTotalFeesToken0, rebalances: 0, ilToken0: frIL, netPnLToken0: frNetPnL, @@ -420,16 +485,17 @@ contract BaselineStrategies is IUniswapV3MintCallback { { (uint256 fw0, uint256 fw1) = _positionAmounts(fwLiquidity, fwLo, fwHi, sqrtPriceX96); uint256 fwPosValue = _valueInToken0(fw0, fw1, sqrtPriceX96); - uint256 fwFeesT0 = _valueInToken0(fwFees0, fwFees1, 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 = fwTotalIL + int256(fwFeesT0); + int256 fwNetPnL = int256(fwFinalValue) - int256(fwInitialValueToken0); fwResult = StrategyResult({ initialCapitalToken0: fwInitialValueToken0, - finalValueToken0: uint256(int256(fwInitialValueToken0) + fwNetPnL), - feesToken0: fwFeesT0, + finalValueToken0: fwFinalValue, + feesToken0: fwTotalFeesToken0, rebalances: fwRebalances, ilToken0: fwTotalIL, netPnLToken0: fwNetPnL, @@ -463,7 +529,11 @@ contract BaselineStrategies is IUniswapV3MintCallback { // ------------------------------------------------------------------------- function _rebalanceFixedWidth(int24 currentTick) internal { - (uint160 sqrtPriceX96,,,,,, ) = pool.slot0(); + (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); @@ -474,6 +544,7 @@ contract BaselineStrategies is IUniswapV3MintCallback { 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. @@ -502,6 +573,7 @@ contract BaselineStrategies is IUniswapV3MintCallback { fwPeriodEntryToken1 = used1; } + // Log uses oldLo/oldHi captured before fwLo/fwHi were overwritten. console2.log( string.concat( "[BASELINE][FW][REBALANCE] #", @@ -509,9 +581,9 @@ contract BaselineStrategies is IUniswapV3MintCallback { " tick=", int256(currentTick).istr(), " oldRange=[", - int256(fwLo).istr(), + int256(oldLo).istr(), ",", - int256(fwHi).istr(), + int256(oldHi).istr(), "] newRange=[", int256(newLo).istr(), ",", diff --git a/onchain/script/backtesting/Reporter.sol b/onchain/script/backtesting/Reporter.sol index 1b79576..6e62120 100644 --- a/onchain/script/backtesting/Reporter.sol +++ b/onchain/script/backtesting/Reporter.sol @@ -193,7 +193,7 @@ contract Reporter { if (r.isHodl) { tir = "N/A"; } else if (r.totalBlocks == 0) { - tir = "100.00%"; + tir = "0.00%"; } else { tir = _bpsStr((r.blocksInRange * 10_000) / r.totalBlocks); }